├── tests ├── drf │ ├── __init__.py │ ├── test_throttling.py │ └── models │ │ └── test_choices.py ├── tools │ ├── __init__.py │ └── test_email.py ├── configuration │ ├── test_secret │ ├── __init__.py │ └── test_secret_file.py ├── __init__.py ├── marketing │ ├── __init__.py │ ├── backends │ │ ├── __init__.py │ │ └── test_brevo_backend.py │ ├── test_marketing_detection_lazy_handler.py │ ├── test_handler.py │ └── test_tasks.py ├── test_project │ ├── __init__.py │ ├── user │ │ ├── __init__.py │ │ ├── apps.py │ │ └── models.py │ ├── oidc_resource_server │ │ ├── __init__.py │ │ ├── serializers.py │ │ └── views.py │ ├── manage.py │ ├── wsgi.py │ ├── urls.py │ └── settings.py ├── oidc_login │ ├── __init__.py │ ├── test_urls.py │ ├── test_decorators.py │ └── test_middleware.py ├── malware_detection │ ├── __init__.py │ ├── backends │ │ ├── __init__.py │ │ └── test_dummy_backend.py │ ├── tasks │ │ ├── __init__.py │ │ └── test_jcop_tasks.py │ ├── test_malware_detection_lazy_handler.py │ ├── test_check_analysis_pending_command.py │ ├── test_handler.py │ └── test_malware_detection_reschedule_processing_analysis_command.py ├── oidc_resource_server │ ├── __init__.py │ ├── test_views.py │ ├── test_authentication_get_access_token.py │ ├── test_utils.py │ └── test_clients.py ├── test_dummy.py ├── factories.py └── conftest.py ├── src └── lasuite │ ├── malware_detection │ ├── management │ │ ├── __init__.py │ │ └── commands │ │ │ ├── __init__.py │ │ │ ├── check_analysis_pending.py │ │ │ └── reschedule_processing_analysis.py │ ├── backends │ │ ├── __init__.py │ │ ├── dummy.py │ │ ├── base.py │ │ └── jcop.py │ ├── migrations │ │ ├── __init__.py │ │ ├── 0004_malwaredetection_file_hash.py │ │ ├── 0002_alter_malwaredetection_parameters.py │ │ ├── 0003_malwaredetection_error_code_and_more.py │ │ └── 0001_initial.py │ ├── tasks │ │ ├── __init__.py │ │ └── jcop.py │ ├── enums.py │ ├── exceptions.py │ ├── __init__.py │ ├── admin.py │ ├── handler.py │ └── models.py │ ├── __init__.py │ ├── drf │ ├── __init__.py │ ├── throttling.py │ └── models │ │ └── choices.py │ ├── oidc_login │ ├── __init__.py │ ├── enums.py │ ├── decorators.py │ ├── urls.py │ └── middleware.py │ ├── configuration │ ├── __init__.py │ └── values.py │ ├── oidc_resource_server │ ├── __init__.py │ ├── urls.py │ ├── views.py │ ├── utils.py │ ├── mixins.py │ ├── clients.py │ └── authentication.py │ ├── tools │ ├── __init__.py │ └── email.py │ └── marketing │ ├── backends │ ├── __init__.py │ ├── dummy.py │ ├── base.py │ └── brevo.py │ ├── exceptions.py │ ├── __init__.py │ ├── tasks.py │ └── handler.py ├── .github ├── PULL_REQUEST_TEMPLATE.md ├── ISSUE_TEMPLATE │ ├── Bug_report.md │ ├── Support_question.md │ └── Feature_request.md └── workflows │ ├── publish.yml │ └── tests.yml ├── setup.py ├── renovate.json ├── .gitignore ├── tox.ini ├── README.md ├── LICENSE ├── gitlint └── gitlint_emoji.py ├── documentation ├── how-to-use-marketing-backend.md ├── how-to-use-oidc-back-channel-logout.md ├── how-to-use-oidc-resource-server-backend.md ├── how-to-use-oidc-backend.md ├── how-to-use-malware-detection-backend.md └── how-to-use-oidc-call-to-resource-server.md ├── .gitlint ├── Makefile ├── pyproject.toml └── CHANGELOG.md /tests/drf/__init__.py: -------------------------------------------------------------------------------- 1 | """Test DRF.""" 2 | -------------------------------------------------------------------------------- /tests/tools/__init__.py: -------------------------------------------------------------------------------- 1 | """Tools tests.""" 2 | -------------------------------------------------------------------------------- /src/lasuite/malware_detection/management/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/configuration/test_secret: -------------------------------------------------------------------------------- 1 | TestSecretInFile 2 | -------------------------------------------------------------------------------- /src/lasuite/__init__.py: -------------------------------------------------------------------------------- 1 | """Django La Suite library.""" 2 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | """Test suite for the library.""" 2 | -------------------------------------------------------------------------------- /src/lasuite/malware_detection/management/commands/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/configuration/__init__.py: -------------------------------------------------------------------------------- 1 | """Test configuration.""" 2 | -------------------------------------------------------------------------------- /tests/marketing/__init__.py: -------------------------------------------------------------------------------- 1 | """Marketing tests module.""" 2 | -------------------------------------------------------------------------------- /tests/test_project/__init__.py: -------------------------------------------------------------------------------- 1 | """Django test project.""" 2 | -------------------------------------------------------------------------------- /src/lasuite/drf/__init__.py: -------------------------------------------------------------------------------- 1 | """drf related code application.""" 2 | -------------------------------------------------------------------------------- /tests/oidc_login/__init__.py: -------------------------------------------------------------------------------- 1 | """Core authentication tests.""" 2 | -------------------------------------------------------------------------------- /tests/test_project/user/__init__.py: -------------------------------------------------------------------------------- 1 | """Testing user model.""" 2 | -------------------------------------------------------------------------------- /src/lasuite/oidc_login/__init__.py: -------------------------------------------------------------------------------- 1 | """OIDC authentication module.""" 2 | -------------------------------------------------------------------------------- /tests/malware_detection/__init__.py: -------------------------------------------------------------------------------- 1 | """Test malware detection.""" 2 | -------------------------------------------------------------------------------- /tests/marketing/backends/__init__.py: -------------------------------------------------------------------------------- 1 | """Marketing backends module.""" 2 | -------------------------------------------------------------------------------- /tests/oidc_resource_server/__init__.py: -------------------------------------------------------------------------------- 1 | """Core resource server tests.""" 2 | -------------------------------------------------------------------------------- /src/lasuite/configuration/__init__.py: -------------------------------------------------------------------------------- 1 | """Custom values for django-configurations.""" 2 | -------------------------------------------------------------------------------- /src/lasuite/oidc_resource_server/__init__.py: -------------------------------------------------------------------------------- 1 | """Backend resource server module.""" 2 | -------------------------------------------------------------------------------- /tests/malware_detection/backends/__init__.py: -------------------------------------------------------------------------------- 1 | """Tests malware detection backends.""" 2 | -------------------------------------------------------------------------------- /src/lasuite/tools/__init__.py: -------------------------------------------------------------------------------- 1 | """Common tools for the LaSuite package or the projects.""" 2 | -------------------------------------------------------------------------------- /tests/malware_detection/tasks/__init__.py: -------------------------------------------------------------------------------- 1 | """Tests for the malware detection tasks.""" 2 | -------------------------------------------------------------------------------- /src/lasuite/malware_detection/backends/__init__.py: -------------------------------------------------------------------------------- 1 | """malware_detection backends module.""" 2 | -------------------------------------------------------------------------------- /src/lasuite/malware_detection/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | """Migrations for the malware detection app.""" 2 | -------------------------------------------------------------------------------- /tests/test_project/oidc_resource_server/__init__.py: -------------------------------------------------------------------------------- 1 | """Dummy application to test resource server.""" 2 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | ## Purpose 2 | 3 | Description... 4 | 5 | 6 | ## Proposal 7 | 8 | Description... 9 | 10 | - [] item 1... 11 | - [] item 2... 12 | -------------------------------------------------------------------------------- /tests/test_dummy.py: -------------------------------------------------------------------------------- 1 | """A dummy test to check if the test suite is working.""" 2 | 3 | 4 | def test_success(): 5 | """A dummy test to check if the test suite is working.""" 6 | assert True 7 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | """Setup file for the package.""" 2 | 3 | from setuptools import setup 4 | 5 | # This file is kept for compatibility with older tools 6 | # The build configuration is primarily in pyproject.toml 7 | setup() 8 | -------------------------------------------------------------------------------- /src/lasuite/malware_detection/tasks/__init__.py: -------------------------------------------------------------------------------- 1 | """Tasks for the malware detection service.""" 2 | 3 | from .jcop import analyse_file_async, trigger_new_analysis 4 | 5 | __all__ = ["analyse_file_async", "trigger_new_analysis"] 6 | -------------------------------------------------------------------------------- /src/lasuite/oidc_resource_server/urls.py: -------------------------------------------------------------------------------- 1 | """Resource Server URL Configuration.""" 2 | 3 | from django.urls import path 4 | 5 | from .views import JWKSView 6 | 7 | urlpatterns = [ 8 | path("jwks", JWKSView.as_view(), name="resource_server_jwks"), 9 | ] 10 | -------------------------------------------------------------------------------- /src/lasuite/malware_detection/enums.py: -------------------------------------------------------------------------------- 1 | """Module contains the enums for the malware detection system.""" 2 | 3 | from enum import StrEnum 4 | 5 | 6 | class ReportStatus(StrEnum): 7 | """The status of a report.""" 8 | 9 | SAFE = "safe" 10 | UNSAFE = "unsafe" 11 | UNKNOWN = "unknown" 12 | -------------------------------------------------------------------------------- /src/lasuite/oidc_login/enums.py: -------------------------------------------------------------------------------- 1 | """Enum for OIDC login.""" 2 | 3 | from enum import StrEnum 4 | 5 | 6 | # OIDC_OP_USER_ENDPOINT allowed values 7 | class OIDCUserEndpointFormat(StrEnum): 8 | """Enum for OIDC OP User Endpoint.""" 9 | 10 | JWT = "jwt" 11 | JSON = "json" 12 | AUTO = "auto" 13 | -------------------------------------------------------------------------------- /tests/test_project/user/apps.py: -------------------------------------------------------------------------------- 1 | """User customization application.""" 2 | 3 | from django.apps import AppConfig 4 | 5 | 6 | class UserConfig(AppConfig): 7 | """Configuration class for the user app.""" 8 | 9 | name = "test_project.user" 10 | verbose_name = "User manager" 11 | app_label = "user" 12 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "extends": ["github>numerique-gouv/renovate-configuration"], 4 | "dependencyDashboard": true, 5 | "labels": ["dependencies", "noChangeLog", "automated"], 6 | "enabledManagers": ["docker-compose", "dockerfile", "github-actions" ,"npm", "setup-cfg", "pep621"] 7 | } 8 | -------------------------------------------------------------------------------- /tests/test_project/manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | """Test project management script.""" 3 | 4 | import os 5 | import sys 6 | 7 | from django.core.management import execute_from_command_line 8 | 9 | if __name__ == "__main__": 10 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "test_project.settings") 11 | 12 | execute_from_command_line(sys.argv) 13 | -------------------------------------------------------------------------------- /src/lasuite/marketing/backends/__init__.py: -------------------------------------------------------------------------------- 1 | """Marketing backends module.""" 2 | 3 | from dataclasses import dataclass 4 | 5 | 6 | @dataclass 7 | class ContactData: 8 | """Contact data for marketing service integration.""" 9 | 10 | email: str 11 | attributes: dict[str, str] | None = None 12 | list_ids: list[int] | None = None 13 | update_enabled: bool = True 14 | -------------------------------------------------------------------------------- /src/lasuite/marketing/exceptions.py: -------------------------------------------------------------------------------- 1 | """Marketing exceptions module.""" 2 | 3 | 4 | class MarketingError(Exception): 5 | """Base exception for all marketing exceptions.""" 6 | 7 | 8 | class MarketingInvalidBackendError(MarketingError): 9 | """Exception raised when the backend is invalid.""" 10 | 11 | 12 | class ContactCreationError(MarketingError): 13 | """Exception raised when the contact creation fails.""" 14 | -------------------------------------------------------------------------------- /tests/oidc_login/test_urls.py: -------------------------------------------------------------------------------- 1 | """Unit tests for the Authentication URLs.""" 2 | 3 | from lasuite.oidc_login.urls import urlpatterns 4 | 5 | 6 | def test_urls_override_default_mozilla_django_oidc(): 7 | """Custom URL patterns should override default ones from Mozilla Django OIDC.""" 8 | url_names = [u.name for u in urlpatterns] 9 | assert url_names.index("oidc_logout_custom") < url_names.index("oidc_logout") 10 | -------------------------------------------------------------------------------- /src/lasuite/marketing/backends/dummy.py: -------------------------------------------------------------------------------- 1 | """Dummy marketing backend.""" 2 | 3 | from lasuite.marketing.backends import ContactData 4 | 5 | from .base import BaseBackend 6 | 7 | 8 | class DummyBackend(BaseBackend): 9 | """Dummy marketing backend doing nothing.""" 10 | 11 | def create_or_update_contact(self, contact_data: ContactData, timeout: int = None) -> dict: 12 | """Create or update a contact.""" 13 | return {} 14 | -------------------------------------------------------------------------------- /src/lasuite/oidc_login/decorators.py: -------------------------------------------------------------------------------- 1 | """ 2 | Decorators for the authentication app. 3 | We don't want (yet) to enforce the OIDC access token to be "fresh" for all 4 | views, so we provide a decorator to refresh the access token only when needed. 5 | """ 6 | 7 | from django.utils.decorators import decorator_from_middleware 8 | 9 | from .middleware import RefreshOIDCAccessToken 10 | 11 | refresh_oidc_access_token = decorator_from_middleware(RefreshOIDCAccessToken) 12 | -------------------------------------------------------------------------------- /tests/marketing/test_marketing_detection_lazy_handler.py: -------------------------------------------------------------------------------- 1 | """Test the marketing lazy handler.""" 2 | 3 | from lasuite.marketing import marketing 4 | from lasuite.marketing.backends.dummy import DummyBackend 5 | 6 | 7 | def test_marketing_lazy_handler(settings): 8 | """Test the marketing lazy handler.""" 9 | settings.LASUITE_MARKETING = { 10 | "BACKEND": "lasuite.marketing.backends.dummy.DummyBackend", 11 | } 12 | assert isinstance(marketing, DummyBackend) 13 | -------------------------------------------------------------------------------- /tests/test_project/wsgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | WSGI config for the test project. 3 | 4 | It exposes the WSGI callable as a module-level variable named ``application``. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/3.1/howto/deployment/wsgi/ 8 | """ 9 | 10 | import os 11 | 12 | from django.core.wsgi import get_wsgi_application 13 | 14 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "test_project.settings") 15 | 16 | application = get_wsgi_application() 17 | -------------------------------------------------------------------------------- /src/lasuite/marketing/__init__.py: -------------------------------------------------------------------------------- 1 | """Marketing module.""" 2 | 3 | from django.utils.functional import LazyObject 4 | 5 | from .handler import MarketingHandler 6 | 7 | 8 | class DefaultMarketing(LazyObject): 9 | """Lazy object to handle the marketing backend.""" 10 | 11 | def _setup(self): 12 | """Configure the marketing backend.""" 13 | self._wrapped = marketing_handler() 14 | 15 | 16 | marketing_handler = MarketingHandler() 17 | marketing = DefaultMarketing() 18 | -------------------------------------------------------------------------------- /tests/test_project/oidc_resource_server/serializers.py: -------------------------------------------------------------------------------- 1 | """Simple user serializer for testing purposes.""" 2 | 3 | from django.contrib.auth import get_user_model 4 | from rest_framework import serializers 5 | 6 | 7 | class UserSerializer(serializers.ModelSerializer): 8 | """User serializer dedicated to test the resource server.""" 9 | 10 | class Meta: # noqa: D106 11 | model = get_user_model() 12 | fields = ("id", "name", "email") 13 | read_only_fields = ("id",) 14 | -------------------------------------------------------------------------------- /src/lasuite/malware_detection/exceptions.py: -------------------------------------------------------------------------------- 1 | """maleware detection exceptions module.""" 2 | 3 | 4 | class MalwareDetectionError(Exception): 5 | """Base exception for all malware detection exceptions.""" 6 | 7 | 8 | class MalwareDetectionInvalidAuthenticationError(MalwareDetectionError): 9 | """Exception raised when the authentication is invalid.""" 10 | 11 | 12 | class MalwareDetectionInvalidBackendError(MalwareDetectionError): 13 | """Exception raised when the backend is invalid.""" 14 | -------------------------------------------------------------------------------- /src/lasuite/malware_detection/__init__.py: -------------------------------------------------------------------------------- 1 | """malware detection module.""" 2 | 3 | from django.utils.functional import LazyObject 4 | 5 | from .handler import MalwareDetectionHandler 6 | 7 | 8 | class DefaultMalwareDetection(LazyObject): 9 | """Lazy object to handle the malware detection backend.""" 10 | 11 | def _setup(self): 12 | """Configure the malware detection backend.""" 13 | self._wrapped = malware_detection_handler() 14 | 15 | 16 | malware_detection_handler = MalwareDetectionHandler() 17 | malware_detection = DefaultMalwareDetection() 18 | -------------------------------------------------------------------------------- /src/lasuite/malware_detection/migrations/0004_malwaredetection_file_hash.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 5.2.3 on 2025-12-04 10:49 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('malware_detection', '0003_malwaredetection_error_code_and_more'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AddField( 14 | model_name='malwaredetection', 15 | name='file_hash', 16 | field=models.CharField(blank=True, help_text='hash of the file', max_length=255), 17 | ), 18 | ] 19 | -------------------------------------------------------------------------------- /src/lasuite/marketing/tasks.py: -------------------------------------------------------------------------------- 1 | """Marketing tasks module.""" 2 | 3 | from celery import shared_task 4 | 5 | from lasuite.marketing import marketing 6 | from lasuite.marketing.backends import ContactData 7 | 8 | 9 | @shared_task 10 | def create_or_update_contact( 11 | email: str, 12 | attributes: dict[str, str] | None = None, 13 | list_ids: list[int] | None = None, 14 | update_enabled: bool = True, 15 | timeout: int = None, 16 | ): 17 | """Create or update a contact.""" 18 | contact_data = ContactData(email=email, attributes=attributes, list_ids=list_ids, update_enabled=update_enabled) 19 | return marketing.create_or_update_contact(contact_data, timeout) 20 | -------------------------------------------------------------------------------- /tests/test_project/oidc_resource_server/views.py: -------------------------------------------------------------------------------- 1 | """Simple viewset for testing purposes.""" 2 | 3 | from django.contrib.auth import get_user_model 4 | from rest_framework import mixins, viewsets 5 | 6 | from lasuite.oidc_resource_server.mixins import ResourceServerMixin 7 | 8 | from . import serializers 9 | 10 | User = get_user_model() 11 | 12 | 13 | class UserViewSet( # pylint: disable=too-many-ancestors 14 | ResourceServerMixin, 15 | mixins.ListModelMixin, 16 | viewsets.GenericViewSet, 17 | ): 18 | """User ViewSet dedicated to test the resource server.""" 19 | 20 | permission_classes = [] 21 | serializer_class = serializers.UserSerializer 22 | queryset = User.objects.all() 23 | -------------------------------------------------------------------------------- /src/lasuite/malware_detection/migrations/0002_alter_malwaredetection_parameters.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 5.2.3 on 2025-08-28 13:56 2 | 3 | import lasuite.malware_detection.models 4 | from django.db import migrations, models 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ('malware_detection', '0001_initial'), 11 | ] 12 | 13 | operations = [ 14 | migrations.AlterField( 15 | model_name='malwaredetection', 16 | name='parameters', 17 | field=models.JSONField(blank=True, default=dict, encoder=lasuite.malware_detection.models.JsonUUIDEncoder, help_text='parameters for the detection', null=True), 18 | ), 19 | ] 20 | -------------------------------------------------------------------------------- /tests/malware_detection/backends/test_dummy_backend.py: -------------------------------------------------------------------------------- 1 | """Test malware_detection dummy backend.""" 2 | 3 | from unittest.mock import MagicMock 4 | 5 | from lasuite.malware_detection.backends.dummy import DummyBackend 6 | from lasuite.malware_detection.enums import ReportStatus 7 | 8 | dummy_callback = MagicMock() 9 | 10 | 11 | def test_dummy_backend(): 12 | """Assert the dummy backend calls the callback with safe status and the correct kwargs.""" 13 | backend = DummyBackend(callback_path="tests.malware_detection.backends.test_dummy_backend.dummy_callback") 14 | backend.analyse_file("foo.txt", foo="bar") 15 | 16 | dummy_callback.assert_called_once_with("foo.txt", ReportStatus.SAFE, error_info={}, foo="bar") 17 | -------------------------------------------------------------------------------- /src/lasuite/malware_detection/management/commands/check_analysis_pending.py: -------------------------------------------------------------------------------- 1 | """Management command to check for pending analyses and trigger them if needed.""" 2 | 3 | from django.core.management.base import BaseCommand 4 | 5 | from lasuite.malware_detection import MalwareDetectionHandler 6 | 7 | 8 | class Command(BaseCommand): 9 | """Management command to check for pending analyses and trigger them if needed.""" 10 | 11 | help = "Call, for the active backend, the method launch_next_analysisin order to not have pending analysis blocked." 12 | 13 | def handle(self, *args, **options): 14 | """Handle the command.""" 15 | backend = MalwareDetectionHandler()() 16 | backend.launch_next_analysis() 17 | -------------------------------------------------------------------------------- /tests/test_project/urls.py: -------------------------------------------------------------------------------- 1 | """Test project URL configuration.""" 2 | 3 | from django.urls import include, path 4 | from rest_framework.routers import DefaultRouter 5 | from test_project.oidc_resource_server.views import UserViewSet 6 | 7 | from lasuite.oidc_login.urls import urlpatterns as oidc_urls 8 | from lasuite.oidc_resource_server.urls import urlpatterns as resource_server_urls 9 | 10 | router = DefaultRouter() 11 | router.register("users", UserViewSet, basename="users") 12 | 13 | 14 | urlpatterns = [ 15 | path( 16 | "", 17 | include( 18 | [ 19 | *oidc_urls, 20 | *resource_server_urls, 21 | *router.urls, 22 | ] 23 | ), 24 | ), 25 | ] 26 | -------------------------------------------------------------------------------- /src/lasuite/tools/email.py: -------------------------------------------------------------------------------- 1 | """Email related tools.""" 2 | 3 | from email.errors import HeaderParseError 4 | from email.headerregistry import Address 5 | 6 | 7 | def get_domain_from_email(email: str | None) -> str | None: 8 | """Extract domain from email.""" 9 | try: 10 | address = Address(addr_spec=email) 11 | if len(address.username) > 64 or len(address.domain) > 255: # noqa: PLR2004 12 | # Simple length validation using the RFC 5321 limits 13 | return None 14 | if not address.domain: 15 | # If the domain is empty, return None 16 | return None 17 | return address.domain 18 | except (ValueError, AttributeError, IndexError, HeaderParseError): 19 | return None 20 | -------------------------------------------------------------------------------- /tests/test_project/user/models.py: -------------------------------------------------------------------------------- 1 | """User model for the application.""" 2 | 3 | from django.contrib.auth.base_user import AbstractBaseUser 4 | from django.db import models 5 | 6 | 7 | class User(AbstractBaseUser): 8 | """User model for the application.""" 9 | 10 | sub = models.CharField("sub", max_length=255, unique=True, null=True) # noqa: DJ001 11 | name = models.CharField("name", max_length=255, blank=True, null=True) # noqa: DJ001 12 | email = models.EmailField("email address", blank=True, null=True) # noqa: DJ001 13 | is_active = models.BooleanField("active", default=True) 14 | USERNAME_FIELD = "email" 15 | 16 | def __str__(self): 17 | """Return a string representation of the user.""" 18 | return self.sub 19 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/Bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: 🐛 Bug Report 3 | about: If something is not working as expected 🤔. 4 | 5 | --- 6 | 7 | ## Bug Report 8 | 9 | **Problematic behavior** 10 | A clear and concise description of the behavior. 11 | 12 | **Expected behavior/code** 13 | A clear and concise description of what you expected to happen (or code). 14 | 15 | **Steps to Reproduce** 16 | 1. Do this... 17 | 2. Then this... 18 | 3. And then the bug happens! 19 | 20 | **Environment** 21 | - Library version: 22 | - Platform: 23 | 24 | **Possible Solution** 25 | 26 | 27 | **Additional context/Screenshots** 28 | Add any other context about the problem here. If applicable, add screenshots to help explain. 29 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Python 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | *.so 6 | .Python 7 | build/ 8 | develop-eggs/ 9 | dist/ 10 | downloads/ 11 | eggs/ 12 | .eggs/ 13 | lib/ 14 | lib64/ 15 | parts/ 16 | sdist/ 17 | var/ 18 | wheels/ 19 | *.egg-info/ 20 | .installed.cfg 21 | *.egg 22 | 23 | # Django 24 | *.log 25 | local_settings.py 26 | db.sqlite3 27 | db.sqlite3-journal 28 | media/ 29 | staticfiles/ 30 | 31 | # Virtual Environment 32 | venv/ 33 | env/ 34 | ENV/ 35 | 36 | # Unit test / coverage reports 37 | htmlcov/ 38 | .tox/ 39 | .coverage 40 | .coverage.* 41 | .cache 42 | coverage.xml 43 | *.cover 44 | 45 | # uv 46 | uv.lock 47 | 48 | # Editor/IDE specific 49 | .idea/ 50 | .vscode/ 51 | *.swp 52 | *.swo 53 | 54 | # OS specific files 55 | .DS_Store 56 | Thumbs.db 57 | -------------------------------------------------------------------------------- /src/lasuite/marketing/backends/base.py: -------------------------------------------------------------------------------- 1 | """Marketing backend base module.""" 2 | 3 | from abc import ABC, abstractmethod 4 | 5 | from lasuite.marketing.backends import ContactData 6 | 7 | 8 | class BaseBackend(ABC): 9 | """Base class for all marketing backends.""" 10 | 11 | @abstractmethod 12 | def create_or_update_contact(self, contact_data: ContactData, timeout: int = None) -> dict: 13 | """ 14 | Create or update a contact. 15 | 16 | Args: 17 | contact_data: Contact information and attributes 18 | timeout: API request timeout in seconds 19 | 20 | Returns: 21 | dict: Service response 22 | 23 | Raises: 24 | ContactCreationError: If contact creation fails 25 | 26 | """ 27 | -------------------------------------------------------------------------------- /src/lasuite/oidc_login/urls.py: -------------------------------------------------------------------------------- 1 | """Authentication URLs for the OIDC backend.""" 2 | 3 | from django.urls import path 4 | from mozilla_django_oidc.urls import urlpatterns as mozilla_oidc_urls 5 | 6 | from .views import OIDCBackChannelLogoutView, OIDCLogoutCallbackView, OIDCLogoutView 7 | 8 | urlpatterns = [ 9 | # Override the default 'logout/' path from Mozilla Django OIDC with our custom view. 10 | path("logout/", OIDCLogoutView.as_view(), name="oidc_logout_custom"), 11 | path( 12 | "logout-callback/", 13 | OIDCLogoutCallbackView.as_view(), 14 | name="oidc_logout_callback", 15 | ), 16 | path( 17 | "backchannel-logout/", 18 | OIDCBackChannelLogoutView.as_view(), 19 | name="oidc_backchannel_logout", 20 | ), 21 | *mozilla_oidc_urls, 22 | ] 23 | -------------------------------------------------------------------------------- /tests/malware_detection/test_malware_detection_lazy_handler.py: -------------------------------------------------------------------------------- 1 | """Test the malware detection lazy handler.""" 2 | 3 | from lasuite.malware_detection import malware_detection 4 | from lasuite.malware_detection.backends.dummy import DummyBackend 5 | 6 | 7 | def test_malware_detection_lazy_handler(settings): 8 | """Test the malware detection lazy handler.""" 9 | settings.MALWARE_DETECTION = { 10 | "BACKEND": "lasuite.malware_detection.backends.dummy.DummyBackend", 11 | "PARAMETERS": { 12 | "callback_path": "tests.malware_detection.backends.test_dummy_backend.dummy_callback", 13 | }, 14 | } 15 | assert isinstance(malware_detection, DummyBackend) 16 | assert malware_detection.callback_path == "tests.malware_detection.backends.test_dummy_backend.dummy_callback" 17 | -------------------------------------------------------------------------------- /src/lasuite/malware_detection/backends/dummy.py: -------------------------------------------------------------------------------- 1 | """Module contains the dummy backend for the malware detection system.""" 2 | 3 | from ..enums import ReportStatus 4 | from ..models import MalwareDetection 5 | from .base import BaseBackend 6 | 7 | 8 | class DummyBackend(BaseBackend): 9 | """A dummy backend that does nothing.""" 10 | 11 | def analyse_file(self, file_path: str, **kwargs) -> None: 12 | """Analyse a file and call the callback with the result.""" 13 | self.callback(file_path, ReportStatus.SAFE, error_info={}, **kwargs) 14 | 15 | def launch_next_analysis(self) -> None: 16 | """Launch the next analysis.""" 17 | 18 | def reschedule_processing_task(self, malware_detection_record: MalwareDetection) -> None: 19 | """Reschedule the processing task for a malware detection record.""" 20 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | requires = 3 | tox>=4.11.4 4 | tox-uv>=0.0.4 5 | envlist = py{311,312,313,314}-django{42,50,51,52} 6 | isolated_build = True 7 | 8 | [gh] 9 | python = 10 | 3.14 = py314-django{52} 11 | 3.13 = py313-django{42,50,51,52} 12 | 3.12 = py312-django{42,50,51,52} 13 | 3.11 = py311-django{42,50,51,52} 14 | 15 | [testenv] 16 | runner = uv-venv-runner 17 | extras = 18 | dev 19 | deps = 20 | django42: Django>=4.2,<4.3 21 | django50: Django>=5.0,<5.1 22 | django51: Django>=5.1,<5.2 23 | django52: Django>=5.2,<5.3 24 | junitxml 25 | setenv = 26 | PYTHONPATH = {toxinidir}:{toxinidir}/tests 27 | DJANGO_SETTINGS_MODULE = test_project.settings 28 | commands = 29 | python -m pytest tests {posargs} \ 30 | --junitxml={envdir}/junit.xml \ 31 | --junit-prefix={envname} 32 | -------------------------------------------------------------------------------- /tests/factories.py: -------------------------------------------------------------------------------- 1 | """Factories for creating test data.""" 2 | 3 | import factory.django 4 | from django.contrib.auth import get_user_model 5 | 6 | from lasuite.malware_detection.models import MalwareDetection 7 | 8 | User = get_user_model() 9 | 10 | 11 | class UserFactory(factory.django.DjangoModelFactory): 12 | """A factory to create random users for testing purposes.""" 13 | 14 | sub = factory.Sequence(lambda n: f"user{n!s}") 15 | email = factory.Faker("email") 16 | name = factory.Faker("name") 17 | 18 | class Meta: # noqa: D106 19 | model = User 20 | 21 | 22 | class MalwareDetectionFactory(factory.django.DjangoModelFactory): 23 | """A factory to create random malware detections for testing purposes.""" 24 | 25 | path = factory.Faker("file_path") 26 | 27 | class Meta: # noqa: D106 28 | model = MalwareDetection 29 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Django La Suite 2 | 3 | A Django library for the common requirement for the "La Suite numérique" projects. 4 | 5 | ## Installation 6 | 7 | ```bash 8 | pip install django-lasuite 9 | ``` 10 | 11 | ## Quick Start 12 | 13 | To be documented with the first implementations. 14 | 15 | ## Development 16 | 17 | ### Requirements 18 | 19 | For this project, we use `uv` Python package manager. 20 | Please follow the installation guidelines on the [uv documentation](https://docs.astral.sh/uv/getting-started/installation/). 21 | 22 | ### Setup 23 | 24 | ```bash 25 | # Clone the repository 26 | git clone https://github.com/suitenumerique/django-lasuite.git 27 | cd django-lasuite 28 | 29 | # Install development dependencies 30 | make install-dev 31 | ``` 32 | 33 | ### Testing 34 | 35 | ```bash 36 | make test 37 | ``` 38 | 39 | ### Code Quality 40 | 41 | ```bash 42 | make lint 43 | ``` 44 | 45 | ## License 46 | 47 | MIT License 48 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/Support_question.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: 🤗 Support Question 3 | about: If you have a question 💬, or something was not clear from the docs! 4 | 5 | --- 6 | 7 | 9 | 10 | --- 11 | 12 | Please make sure you have read our [main Readme](https://github.com/suitenumerique/django-lasuite). 13 | 14 | Also make sure it was not already answered in [an open or close issue](https://github.com/suitenumerique/django-lasuite/issues). 15 | 16 | If your question was not covered, and you feel like it should be, fire away! We'd love to improve our docs! 👌 17 | 18 | **Topic** 19 | What's the general area of your question: for example, setup, database schema, search functionality,... 20 | 21 | **Question** 22 | Try to be as specific as possible so we can help you as best we can. Please be patient 🙏 23 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/Feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: ✨ Feature Request 3 | about: I have a suggestion (and may want to build it 💪)! 4 | 5 | --- 6 | 7 | ## Feature Request 8 | 9 | **Is your feature request related to a problem or unsupported use case? Please describe.** 10 | A clear and concise description of what the problem is. For example: I need to do some task and I have an issue... 11 | 12 | **Describe the solution you'd like** 13 | A clear and concise description of what you want to happen. Add any considered drawbacks. 14 | 15 | **Describe alternatives you've considered** 16 | A clear and concise description of any alternative solutions or features you've considered. 17 | 18 | **Discovery, Documentation, Adoption, Migration Strategy** 19 | If you can, explain how users will be able to use this and possibly write out a version the docs (if applicable). 20 | Maybe a screenshot or design? 21 | 22 | **Do you want to work on it through a Pull Request?** 23 | 24 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 La Suite numérique 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /tests/marketing/test_handler.py: -------------------------------------------------------------------------------- 1 | """Test the marketing handler.""" 2 | 3 | import pytest 4 | from django.core.exceptions import ImproperlyConfigured 5 | 6 | from lasuite.marketing.backends.dummy import DummyBackend 7 | from lasuite.marketing.handler import MarketingHandler 8 | 9 | 10 | def test_marketing_handler_from_settings(settings): 11 | """Test the marketing handler from the settings.""" 12 | settings.LASUITE_MARKETING = { 13 | "BACKEND": "lasuite.marketing.backends.dummy.DummyBackend", 14 | } 15 | handler = MarketingHandler() 16 | assert isinstance(handler(), DummyBackend) 17 | 18 | 19 | def test_marketing_handler_from_backend(): 20 | """Test the marketing handler from the backend.""" 21 | handler = MarketingHandler( 22 | backend={ 23 | "BACKEND": "lasuite.marketing.backends.dummy.DummyBackend", 24 | } 25 | ) 26 | assert isinstance(handler(), DummyBackend) 27 | 28 | 29 | def test_marketing_backend_no_config(settings): 30 | """Test the marketing handler when no config set should raise an error.""" 31 | settings.LASUITE_MARKETING = None 32 | handler = MarketingHandler() 33 | with pytest.raises(ImproperlyConfigured): 34 | handler() 35 | -------------------------------------------------------------------------------- /src/lasuite/malware_detection/management/commands/reschedule_processing_analysis.py: -------------------------------------------------------------------------------- 1 | """Management command to reschedule the processing analysis.""" 2 | 3 | from datetime import timedelta 4 | 5 | from django.conf import settings 6 | from django.core.management.base import BaseCommand 7 | from django.utils.timezone import now 8 | 9 | from lasuite.malware_detection.handler import MalwareDetectionHandler 10 | from lasuite.malware_detection.models import MalwareDetection, MalwareDetectionStatus 11 | 12 | 13 | class Command(BaseCommand): 14 | """Management command to reschedule the processing analysis.""" 15 | 16 | def handle(self, *args, **options): 17 | """Handle the command.""" 18 | reschedule_days = getattr(settings, "MALWARE_DETECTION_RESCHEDULE_PROCESSING_ANALYSIS_DAYS", 3) 19 | 20 | reschedule_date = now() - timedelta(days=reschedule_days) 21 | 22 | malware_detections = MalwareDetection.objects.filter( 23 | status=MalwareDetectionStatus.PROCESSING, created_at__lte=reschedule_date 24 | ) 25 | 26 | backend = MalwareDetectionHandler()() 27 | 28 | for malware_detection in malware_detections: 29 | backend.reschedule_processing_task(malware_detection) 30 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | """Fixtures for the test suite.""" 2 | 3 | import django 4 | import pytest 5 | from django.core.files.storage.memory import InMemoryFileNode 6 | from packaging.version import parse 7 | 8 | 9 | @pytest.fixture(autouse=True) 10 | def patch_inmemoryfilenode(): 11 | """ 12 | Monkeypatch InMemoryFileNode to add 'name' property for Django < 5.2 . 13 | 14 | In Django 5.2+, InMemoryFileNode has a 'name' attribute, but in earlier versions 15 | it's missing, causing AttributeError in tests. 16 | 17 | As it should only be used for testing, we patch it in tests. 18 | """ 19 | if parse(django.__version__) < parse("5.2"): 20 | # Only apply the patch for Django versions before 5.2 21 | original_init = InMemoryFileNode.__init__ 22 | 23 | def patched_init(self, *args, name=None, **kwargs): 24 | original_init(self, *args, **kwargs) 25 | # Add name attribute based on path 26 | self.name = name 27 | 28 | InMemoryFileNode.__init__ = patched_init 29 | 30 | # Restore original method after tests 31 | yield 32 | InMemoryFileNode.__init__ = original_init 33 | else: 34 | # No need to patch for Django 5.2+ 35 | yield 36 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Build and Publish to PyPI 2 | 3 | on: 4 | push: 5 | tags: 6 | - 'v*' # Trigger on version tags like v1.0.0 7 | 8 | jobs: 9 | build-and-publish: 10 | name: Build and publish to PyPI 11 | runs-on: ubuntu-latest 12 | #environment: 13 | #name: pypi 14 | #url: https://pypi.org/p/django-lasuite 15 | permissions: 16 | id-token: write # IMPORTANT: this permission is mandatory for trusted publishing 17 | 18 | steps: 19 | - name: Checkout repository 20 | uses: actions/checkout@v5 21 | with: 22 | fetch-depth: 0 # Get full history for proper versioning 23 | 24 | - name: Set up Python 25 | uses: actions/setup-python@v6 26 | with: 27 | python-version: '3.14' 28 | 29 | - name: Install build dependencies 30 | run: | 31 | python -m pip install --upgrade pip 32 | pip install build twine 33 | 34 | - name: Build package 35 | run: python -m build 36 | 37 | - name: Check distribution 38 | run: twine check dist/* 39 | 40 | - name: Publish package distributions to PyPI 41 | uses: pypa/gh-action-pypi-publish@release/v1 42 | # Use TestPyPI for testing 43 | #with: 44 | # repository-url: https://test.pypi.org/legacy/ 45 | -------------------------------------------------------------------------------- /src/lasuite/oidc_resource_server/views.py: -------------------------------------------------------------------------------- 1 | """Resource Server views.""" 2 | 3 | from django.core.exceptions import ImproperlyConfigured 4 | from joserfc.jwk import KeySet 5 | from rest_framework.response import Response 6 | from rest_framework.views import APIView 7 | 8 | from . import utils 9 | 10 | 11 | class JWKSView(APIView): 12 | """ 13 | API endpoint for retrieving a JSON Web Keys Set (JWKS). 14 | 15 | Returns: 16 | Response: JSON response containing the JWKS data. 17 | 18 | """ 19 | 20 | authentication_classes = [] # disable authentication 21 | permission_classes = [] # disable permission 22 | 23 | def get(self, request): 24 | """ 25 | Handle GET requests to retrieve JSON Web Keys Set (JWKS). 26 | 27 | Returns: 28 | Response: JSON response containing the JWKS data. 29 | 30 | """ 31 | try: 32 | private_key = utils.import_private_key_from_settings() 33 | except (ImproperlyConfigured, ValueError) as err: 34 | return Response({"error": str(err)}, status=500) 35 | 36 | try: 37 | jwk = KeySet([private_key]).as_dict(private=False) 38 | except (TypeError, ValueError, AttributeError): 39 | return Response({"error": "Could not load key"}, status=500) 40 | 41 | return Response(jwk) 42 | -------------------------------------------------------------------------------- /src/lasuite/drf/throttling.py: -------------------------------------------------------------------------------- 1 | """Throttling for DRF.""" 2 | 3 | from logging import getLogger 4 | 5 | from django.conf import settings 6 | from django.utils.module_loading import import_string 7 | from rest_framework.throttling import ScopedRateThrottle 8 | 9 | logger = getLogger("lasuite.drf.throttling") 10 | 11 | 12 | def simple_logger_throttle_failure(message): 13 | """Log a warning message when a throttle fails.""" 14 | logger.warning(message) 15 | 16 | 17 | def monitored_throttle_failure(message): 18 | """Import custom callback if existing or use the simple logger.""" 19 | callback_path = getattr(settings, "MONITORED_THROTTLE_FAILURE_CALLBACK", None) 20 | if callback_path is None: 21 | simple_logger_throttle_failure(message) 22 | return 23 | 24 | callback = import_string(callback_path) 25 | callback(message) 26 | 27 | 28 | class MonitoredThrottleMixin: 29 | """Mixin for monitored throttles.""" 30 | 31 | def throttle_failure(self): 32 | """Log when a failure occurs to detect rate limiting issues.""" 33 | monitored_throttle_failure(f"Rate limit exceeded for scope {self.scope}") 34 | return super().throttle_failure() 35 | 36 | 37 | class MonitoredScopedRateThrottle(MonitoredThrottleMixin, ScopedRateThrottle): 38 | """Throttle for the monitored scoped rate throttle.""" 39 | -------------------------------------------------------------------------------- /src/lasuite/malware_detection/admin.py: -------------------------------------------------------------------------------- 1 | """Admin configuration for malware detection.""" 2 | 3 | from django.contrib import admin 4 | 5 | from lasuite.malware_detection.handler import MalwareDetectionHandler 6 | from lasuite.malware_detection.models import MalwareDetection 7 | 8 | 9 | @admin.register(MalwareDetection) 10 | class MalwareDetectionAdmin(admin.ModelAdmin): 11 | """Admin for MalwareDetection model with a simple dashboard and filters.""" 12 | 13 | list_display = ( 14 | "id", 15 | "path", 16 | "status", 17 | "backend", 18 | "error_code", 19 | "file_hash", 20 | "created_at", 21 | "updated_at", 22 | ) 23 | list_filter = ("status",) 24 | search_fields = ("path", "id", "file_hash") 25 | readonly_fields = ("id", "created_at", "updated_at", "file_hash") 26 | ordering = ("-created_at",) 27 | actions = ("reschedule_processing_task",) 28 | 29 | def reschedule_processing_task(self, request, queryset): 30 | """Reschedule the processing task for a malware detection record.""" 31 | backend = MalwareDetectionHandler()() 32 | for malware_detection in queryset: 33 | backend.reschedule_processing_task(malware_detection) 34 | 35 | self.message_user( 36 | request, 37 | "Malware detection tasks successfully rescheduled", 38 | ) 39 | -------------------------------------------------------------------------------- /gitlint/gitlint_emoji.py: -------------------------------------------------------------------------------- 1 | """Gitlint extra rule to validate the message title.""" 2 | 3 | import re 4 | 5 | import requests 6 | from gitlint.rules import CommitMessageTitle, LineRule, RuleViolation 7 | 8 | 9 | class GitmojiTitle(LineRule): 10 | """ 11 | Enforce that each commit title is of the form "() " 12 | where gitmoji is an emoji from the list defined in https://gitmoji.carloscuesta.me and 13 | subject should be all lowercase. 14 | """ 15 | 16 | id = "UC1" 17 | name = "title-should-have-gitmoji-and-scope" 18 | target = CommitMessageTitle 19 | 20 | def validate(self, title, _commit): 21 | """ 22 | Download the list possible gitmojis from the project's GitHub repository and check that 23 | title contains one of them. 24 | """ 25 | gitmojis = requests.get( 26 | "https://raw.githubusercontent.com/carloscuesta/gitmoji/master/packages/gitmojis/src/gitmojis.json", 27 | timeout=10, 28 | ).json()["gitmojis"] 29 | emojis = [item["emoji"] for item in gitmojis] 30 | pattern = r"^({:s})\(.*\)\s[a-z].*$".format("|".join(emojis)) 31 | if not re.search(pattern, title): 32 | violation_msg = 'Title does not match regex "() "' 33 | return [RuleViolation(self.id, violation_msg, title)] 34 | return None 35 | -------------------------------------------------------------------------------- /src/lasuite/malware_detection/migrations/0003_malwaredetection_error_code_and_more.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 5.2.3 on 2025-11-28 14:16 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('malware_detection', '0002_alter_malwaredetection_parameters'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AddField( 14 | model_name='malwaredetection', 15 | name='error_code', 16 | field=models.IntegerField(blank=True, help_text='error code for the detection', null=True), 17 | ), 18 | migrations.AddField( 19 | model_name='malwaredetection', 20 | name='error_msg', 21 | field=models.TextField(blank=True, help_text='error message for the detection'), 22 | ), 23 | migrations.AlterField( 24 | model_name='malwaredetection', 25 | name='status', 26 | field=models.CharField(choices=[('pending', 'Pending'), ('processing', 'Processing'), ('failed', 'Failed')], default='pending', help_text='status of the detection', max_length=255), 27 | ), 28 | migrations.AddField( 29 | model_name='malwaredetection', 30 | name='backend', 31 | field=models.TextField(blank=True, help_text='backend used for the detection'), 32 | ), 33 | ] 34 | -------------------------------------------------------------------------------- /tests/malware_detection/test_check_analysis_pending_command.py: -------------------------------------------------------------------------------- 1 | """Test the check_analysis_pending command.""" 2 | 3 | from unittest import mock 4 | 5 | from django.core.management import call_command 6 | 7 | from lasuite.malware_detection.backends.base import BaseBackend 8 | from lasuite.malware_detection.models import MalwareDetection 9 | 10 | mock_launch_next_analysis = mock.MagicMock() 11 | 12 | 13 | class TestBackend(BaseBackend): 14 | """Test backend.""" 15 | 16 | def launch_next_analysis(self) -> None: 17 | """Launch the next analysis.""" 18 | mock_launch_next_analysis() 19 | 20 | def analyse_file(self, file_path: str, **kwargs) -> None: 21 | """Analyse a file.""" 22 | 23 | def reschedule_processing_task(self, malware_detection_record: MalwareDetection) -> None: 24 | """Reschedule the processing task for a malware detection record.""" 25 | 26 | 27 | def test_check_analysis_pending_command(settings): 28 | """Test the check_analysis_pending command.""" 29 | settings.MALWARE_DETECTION = { 30 | "BACKEND": "tests.malware_detection.test_check_analysis_pending_command.TestBackend", 31 | "PARAMETERS": { 32 | "callback_path": "tests.malware_detection.test_check_analysis_pending_command.dummy_callback", 33 | }, 34 | } 35 | 36 | call_command("check_analysis_pending") 37 | 38 | mock_launch_next_analysis.assert_called_once() 39 | -------------------------------------------------------------------------------- /src/lasuite/malware_detection/backends/base.py: -------------------------------------------------------------------------------- 1 | """malware_detection backend base module to implement.""" 2 | 3 | from abc import ABC, abstractmethod 4 | 5 | from django.utils.module_loading import import_string 6 | 7 | from ..models import MalwareDetection 8 | 9 | 10 | class BaseBackend(ABC): 11 | """Base class for all malware detection backends.""" 12 | 13 | def __init__(self, *, callback_path: str, max_processing_files: int = 15, **kwargs): 14 | """Initialize the backend.""" 15 | self.callback_path = callback_path 16 | 17 | if max_processing_files < 0: 18 | raise ValueError("max_processing_files must be greater than or equal to 0") 19 | 20 | self.max_processing_files = max_processing_files 21 | 22 | @property 23 | def backend_name(self): 24 | """Backend full class name to store in malware detection records if needed.""" 25 | return f"{self.__class__.__module__}.{self.__class__.__qualname__}" 26 | 27 | @property 28 | def callback(self): 29 | """Get the callback function.""" 30 | return import_string(self.callback_path) 31 | 32 | @abstractmethod 33 | def analyse_file(self, file_path: str, **kwargs) -> None: 34 | """Analyse a file and call the callback with the result.""" 35 | 36 | @abstractmethod 37 | def launch_next_analysis(self) -> None: 38 | """Launch the next analysis.""" 39 | 40 | @abstractmethod 41 | def reschedule_processing_task(self, malware_detection_record: MalwareDetection) -> None: 42 | """Reschedule the processing task for a malware detection record.""" 43 | -------------------------------------------------------------------------------- /tests/marketing/test_tasks.py: -------------------------------------------------------------------------------- 1 | """Test the marketing tasks.""" 2 | 3 | from unittest import mock 4 | 5 | from lasuite.marketing import tasks 6 | from lasuite.marketing.backends import ContactData 7 | 8 | 9 | def test_create_or_update_contact_success(): 10 | """Test the create_or_update_contact task.""" 11 | with mock.patch.object(tasks, "marketing") as mock_marketing: 12 | mock_marketing.create_or_update_contact = mock.MagicMock() 13 | 14 | contact_data = ContactData( 15 | email="test@example.com", 16 | attributes={"first_name": "Test"}, 17 | list_ids=[1, 2], 18 | update_enabled=True, 19 | ) 20 | 21 | tasks.create_or_update_contact( 22 | email="test@example.com", attributes={"first_name": "Test"}, list_ids=[1, 2], update_enabled=True 23 | ) 24 | 25 | mock_marketing.create_or_update_contact.assert_called_once_with(contact_data, None) 26 | 27 | 28 | def test_create_or_update_contact_with_timeout(): 29 | """Test the create_or_update_contact task.""" 30 | with mock.patch.object(tasks, "marketing") as mock_marketing: 31 | mock_marketing.create_or_update_contact = mock.MagicMock() 32 | 33 | contact_data = ContactData( 34 | email="test@example.com", 35 | attributes={"first_name": "Test"}, 36 | list_ids=[1, 2], 37 | update_enabled=True, 38 | ) 39 | 40 | tasks.create_or_update_contact( 41 | email="test@example.com", 42 | attributes={"first_name": "Test"}, 43 | list_ids=[1, 2], 44 | update_enabled=True, 45 | timeout=30, 46 | ) 47 | 48 | mock_marketing.create_or_update_contact.assert_called_once_with(contact_data, 30) 49 | -------------------------------------------------------------------------------- /src/lasuite/oidc_resource_server/utils.py: -------------------------------------------------------------------------------- 1 | """Resource Server utils functions.""" 2 | 3 | from django.conf import settings 4 | from django.core.exceptions import ImproperlyConfigured 5 | from joserfc import jwk 6 | 7 | 8 | def import_private_key_from_settings(): 9 | """ 10 | Import the private key used by the resource server when interacting with the OIDC provider. 11 | 12 | This private key is crucial; its public components are exposed in the JWK endpoints, 13 | while its private component is used for decrypting the introspection token retrieved 14 | from the OIDC provider. 15 | 16 | By default, we recommend using RSAKey for asymmetric encryption, 17 | known for its strong security features. 18 | 19 | Note: 20 | - The function requires the 'OIDC_RS_PRIVATE_KEY_STR' setting to be configured. 21 | - The 'OIDC_RS_ENCRYPTION_KEY_TYPE' and 'OIDC_RS_ENCRYPTION_ALGO' settings can be customized 22 | based on the chosen key type. 23 | 24 | Raises: 25 | ImproperlyConfigured: If the private key setting is missing, empty, or incorrect. 26 | 27 | Returns: 28 | joserfc.jwk.JWK: The imported private key as a JWK object. 29 | 30 | """ 31 | private_key_str = getattr(settings, "OIDC_RS_PRIVATE_KEY_STR", None) 32 | if not private_key_str: 33 | raise ImproperlyConfigured("OIDC_RS_PRIVATE_KEY_STR setting is missing or empty.") 34 | 35 | try: 36 | private_key = jwk.import_key( 37 | private_key_str, 38 | key_type=settings.OIDC_RS_ENCRYPTION_KEY_TYPE, 39 | parameters={"alg": settings.OIDC_RS_ENCRYPTION_ALGO, "use": "enc"}, 40 | ) 41 | except ValueError as err: 42 | raise ImproperlyConfigured("OIDC_RS_PRIVATE_KEY_STR setting is wrong.") from err 43 | 44 | return private_key 45 | -------------------------------------------------------------------------------- /tests/malware_detection/test_handler.py: -------------------------------------------------------------------------------- 1 | """Test the malware detection handler.""" 2 | 3 | import pytest 4 | from django.core.exceptions import ImproperlyConfigured 5 | 6 | from lasuite.malware_detection.backends.dummy import DummyBackend 7 | from lasuite.malware_detection.handler import MalwareDetectionHandler 8 | 9 | 10 | def test_malware_detection_handler_from_settings(settings): 11 | """Test the malware detection handler from the settings.""" 12 | settings.MALWARE_DETECTION = { 13 | "BACKEND": "lasuite.malware_detection.backends.dummy.DummyBackend", 14 | "PARAMETERS": { 15 | "callback_path": "tests.malware_detection.backends.test_dummy_backend.dummy_callback", 16 | }, 17 | } 18 | handler = MalwareDetectionHandler() 19 | assert isinstance(handler(), DummyBackend) 20 | assert handler().callback_path == "tests.malware_detection.backends.test_dummy_backend.dummy_callback" 21 | 22 | 23 | def test_malware_detection_handler_from_backend(settings): 24 | """Test the malware detection handler from the backend.""" 25 | handler = MalwareDetectionHandler( 26 | backend={ 27 | "BACKEND": "lasuite.malware_detection.backends.dummy.DummyBackend", 28 | "PARAMETERS": { 29 | "callback_path": "tests.malware_detection.backends.test_dummy_backend.dummy_callback", 30 | }, 31 | } 32 | ) 33 | assert isinstance(handler(), DummyBackend) 34 | assert handler().callback_path == "tests.malware_detection.backends.test_dummy_backend.dummy_callback" 35 | 36 | 37 | def test_malware_detection_backend_no_config(settings): 38 | """Test the malware detection handler when no config set should raise an error.""" 39 | settings.MALWARE_DETECTION = None 40 | handler = MalwareDetectionHandler() 41 | with pytest.raises(ImproperlyConfigured): 42 | handler() 43 | -------------------------------------------------------------------------------- /tests/tools/test_email.py: -------------------------------------------------------------------------------- 1 | """Tests for email tools.""" 2 | 3 | import pytest 4 | 5 | from lasuite.tools.email import get_domain_from_email 6 | 7 | 8 | @pytest.mark.parametrize( 9 | ("email", "expected"), 10 | [ 11 | ("user@example.com", "example.com"), 12 | ("test.user@sub.domain.co.uk", "sub.domain.co.uk"), 13 | ("name+tag@gmail.com", "gmail.com"), 14 | ("user@localhost", "localhost"), 15 | ("user@127.0.0.1", "127.0.0.1"), 16 | ], 17 | ) 18 | def test_get_domain_from_email_valid(email, expected): 19 | """Test extracting domain from valid email addresses.""" 20 | assert get_domain_from_email(email) == expected 21 | 22 | 23 | @pytest.mark.parametrize( 24 | "invalid_email", 25 | [ 26 | None, 27 | "", 28 | "invalid-email", 29 | "user@", 30 | "@domain.com", 31 | "user@domain@com", 32 | "user@@domain.com", 33 | "user domain.com", 34 | "@example.com", 35 | "user@example.com\n", 36 | "user@example.com;drop table users", 37 | ], 38 | ) 39 | def test_get_domain_from_email_invalid(invalid_email): 40 | """Test handling of invalid email addresses.""" 41 | assert get_domain_from_email(invalid_email) is None 42 | 43 | 44 | def test_get_domain_from_email_length_limits(): 45 | """Test handling of extremely long email addresses.""" 46 | # Very long local part (should fail) 47 | long_local = "a" * 65 + "@example.com" 48 | assert get_domain_from_email(long_local) is None 49 | 50 | # Very long domain (valid according to standards) 51 | domain_parts = ".".join(["a" * 64] * 4) 52 | long_domain = f"user@{domain_parts}" 53 | # This might fail depending on email validation implementation 54 | # The test expects None since such domains aren't typically valid in practice 55 | assert get_domain_from_email(long_domain) is None 56 | -------------------------------------------------------------------------------- /src/lasuite/marketing/handler.py: -------------------------------------------------------------------------------- 1 | """marketing backendhandler.""" 2 | 3 | from django.conf import settings 4 | from django.core.exceptions import ImproperlyConfigured 5 | from django.utils.functional import cached_property 6 | from django.utils.module_loading import import_string 7 | 8 | from lasuite.marketing.exceptions import MarketingInvalidBackendError 9 | 10 | 11 | class MarketingHandler: 12 | """Marketing handler managing the backend instantiation.""" 13 | 14 | def __init__(self, backend=None): 15 | """Initialize the marketing handler.""" 16 | # backend is an optional dict of marketing backend definitions 17 | # (structured like settings.LASUITE_MARKETING). 18 | self._backend = backend 19 | self._marketing = None 20 | 21 | @cached_property 22 | def backend(self): 23 | """Put in cache the backend properties from the settings.""" 24 | if self._backend is None: 25 | try: 26 | self._backend = settings.LASUITE_MARKETING.copy() 27 | except AttributeError as e: 28 | raise ImproperlyConfigured("settings.LASUITE_MARKETING is not configured") from e 29 | return self._backend 30 | 31 | def __call__(self): 32 | """Create if not existing the backend and then return it.""" 33 | if self._marketing is None: 34 | self._marketing = self.create_marketing(self.backend) 35 | return self._marketing 36 | 37 | def create_marketing(self, params): 38 | """Instantiate and configure the marketing backend.""" 39 | params = params.copy() 40 | backend = params.pop("BACKEND") 41 | parameters = params.pop("PARAMETERS", {}) 42 | try: 43 | klass = import_string(backend) 44 | except ImportError as e: 45 | raise MarketingInvalidBackendError(f"Could not find backend {backend!r}: {e}") from e 46 | return klass(**parameters) 47 | -------------------------------------------------------------------------------- /tests/oidc_login/test_decorators.py: -------------------------------------------------------------------------------- 1 | """Tests for the refresh_oidc_access_token decorator in core app.""" 2 | 3 | from unittest.mock import patch 4 | 5 | from django.http import HttpResponse 6 | from django.test import RequestFactory 7 | from django.utils.decorators import method_decorator 8 | from django.views import View 9 | 10 | from lasuite.oidc_login.decorators import refresh_oidc_access_token 11 | 12 | 13 | class RefreshOIDCAccessTokenView(View): 14 | """ 15 | A Django view that uses the refresh_oidc_access_token decorator to refresh 16 | the OIDC access token before processing the request. 17 | """ 18 | 19 | @method_decorator(refresh_oidc_access_token) 20 | def dispatch(self, request, *args, **kwargs): 21 | """Override the dispatch method to apply the refresh_oidc_access_token decorator.""" 22 | return super().dispatch(request, *args, **kwargs) 23 | 24 | def get(self, request, *args, **kwargs): 25 | """ 26 | Handle GET requests. 27 | 28 | Returns: 29 | HttpResponse: A simple HTTP response with "OK" as the content. 30 | 31 | """ 32 | return HttpResponse("OK") 33 | 34 | 35 | def test_refresh_oidc_access_token_decorator(): 36 | """ 37 | Tests the refresh_oidc_access_token decorator is called on RefreshOIDCAccessTokenView access. 38 | The test creates a mock request and patches the dispatch method to verify that it is called 39 | with the correct request object. 40 | """ 41 | # Create a test request 42 | factory = RequestFactory() 43 | request = factory.get("/") 44 | 45 | # Mock the OIDC refresh functionality 46 | with patch("lasuite.oidc_login.middleware.RefreshOIDCAccessToken.process_request") as mock_refresh: 47 | # Call the decorated view 48 | RefreshOIDCAccessTokenView.as_view()(request) 49 | 50 | # Assert that the refresh method was called 51 | mock_refresh.assert_called_once_with(request) 52 | -------------------------------------------------------------------------------- /src/lasuite/malware_detection/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 5.2.3 on 2025-07-03 15:11 2 | # noqa 3 | import uuid 4 | 5 | from django.db import migrations, models 6 | 7 | 8 | class Migration(migrations.Migration): 9 | initial = True 10 | 11 | dependencies = [] 12 | 13 | operations = [ 14 | migrations.CreateModel( 15 | name="MalwareDetection", 16 | fields=[ 17 | ( 18 | "id", 19 | models.UUIDField( 20 | default=uuid.uuid4, 21 | editable=False, 22 | help_text="primary key for the record as UUID", 23 | primary_key=True, 24 | serialize=False, 25 | ), 26 | ), 27 | ( 28 | "created_at", 29 | models.DateTimeField(auto_now_add=True, help_text="date and time at which a record was created"), 30 | ), 31 | ( 32 | "updated_at", 33 | models.DateTimeField(auto_now=True, help_text="date and time at which a record was last updated"), 34 | ), 35 | ("path", models.CharField(help_text="path to the file", max_length=255, unique=True)), 36 | ( 37 | "status", 38 | models.CharField( 39 | choices=[("pending", "Pending"), ("processing", "Processing")], 40 | default="pending", 41 | help_text="status of the detection", 42 | max_length=255, 43 | ), 44 | ), 45 | ( 46 | "parameters", 47 | models.JSONField(blank=True, default=dict(), help_text="parameters for the detection", null=True), 48 | ), 49 | ], 50 | ), 51 | ] 52 | -------------------------------------------------------------------------------- /src/lasuite/malware_detection/handler.py: -------------------------------------------------------------------------------- 1 | """malware detection backendhandler.""" 2 | 3 | from django.conf import settings 4 | from django.core.exceptions import ImproperlyConfigured 5 | from django.utils.functional import cached_property 6 | from django.utils.module_loading import import_string 7 | 8 | from lasuite.malware_detection.exceptions import MalwareDetectionInvalidBackendError 9 | 10 | 11 | class MalwareDetectionHandler: 12 | """Malware detection handler managing the backend instantiation.""" 13 | 14 | def __init__(self, backend=None): 15 | """Initialize the malware detection handler.""" 16 | # backend is an optional dict of malware detection backend definitions 17 | # (structured like settings.MALWARE_DETECTION). 18 | self._backend = backend 19 | self._malware_detection = None 20 | 21 | @cached_property 22 | def backend(self): 23 | """Put in cache the backend properties from the settings.""" 24 | if self._backend is None: 25 | try: 26 | self._backend = settings.MALWARE_DETECTION.copy() 27 | except AttributeError as e: 28 | raise ImproperlyConfigured("settings.MALWARE_DETECTION is not configured") from e 29 | return self._backend 30 | 31 | def __call__(self): 32 | """Create if not existing the backend and then return it.""" 33 | if self._malware_detection is None: 34 | self._malware_detection = self.create_malware_detection(self.backend) 35 | return self._malware_detection 36 | 37 | def create_malware_detection(self, params): 38 | """Instantiate and configure the malware detection backend.""" 39 | params = params.copy() 40 | backend = params.pop("BACKEND") 41 | parameters = params.pop("PARAMETERS", {}) 42 | try: 43 | klass = import_string(backend) 44 | except ImportError as e: 45 | raise MalwareDetectionInvalidBackendError(f"Could not find backend {backend!r}: {e}") from e 46 | return klass(**parameters) 47 | -------------------------------------------------------------------------------- /src/lasuite/oidc_resource_server/mixins.py: -------------------------------------------------------------------------------- 1 | """Mixins for resource server views.""" 2 | 3 | from rest_framework import exceptions as drf_exceptions 4 | 5 | from .authentication import ResourceServerAuthentication 6 | 7 | 8 | class ResourceServerMixin: 9 | """ 10 | Mixin for resource server views: 11 | - Restrict the authentication class to ResourceServerAuthentication. 12 | - Adds the Service Provider ID to the serializer context. 13 | - Fetch the Service Provider ID from the OIDC introspected token stored 14 | in the request. 15 | 16 | This Mixin *must* be used in every view that should act as a resource server. 17 | """ 18 | 19 | authentication_classes = [ResourceServerAuthentication] 20 | 21 | def get_serializer_context(self): 22 | """Extra context provided to the serializer class.""" 23 | context = super().get_serializer_context() 24 | 25 | # When used as a resource server, we need to know the audience to automatically: 26 | # - add the Service Provider to the team "scope" on creation 27 | context["from_service_provider_audience"] = self._get_service_provider_audience() 28 | 29 | return context 30 | 31 | def _get_service_provider_audience(self): 32 | """Return the audience of the Service Provider from the OIDC introspected token.""" 33 | if not isinstance(self.request.successful_authenticator, ResourceServerAuthentication): 34 | # We could check request.resource_server_token_audience here, but it's 35 | # more explicit to check the authenticator type and assert the attribute 36 | # existence. 37 | return None 38 | 39 | # When used as a resource server, the request has a token audience 40 | service_provider_audience = self.request.resource_server_token_audience 41 | 42 | if not service_provider_audience: # should not happen 43 | raise drf_exceptions.AuthenticationFailed("Resource server token audience not found in request") 44 | 45 | return service_provider_audience 46 | -------------------------------------------------------------------------------- /tests/drf/test_throttling.py: -------------------------------------------------------------------------------- 1 | """Test monitored throtthling.""" 2 | 3 | import logging 4 | 5 | import pytest 6 | from rest_framework.response import Response 7 | from rest_framework.test import APIRequestFactory 8 | from rest_framework.views import APIView 9 | 10 | from lasuite.drf.throttling import MonitoredScopedRateThrottle 11 | 12 | pytestmark = pytest.mark.django_db 13 | 14 | 15 | def custom_callback(message): 16 | """Define custom callback.""" 17 | logging.critical(message) 18 | 19 | 20 | class TestMonitoredScopedRateThrottle(MonitoredScopedRateThrottle): 21 | """Test monitored scoped rate throttle.""" 22 | 23 | __test__ = False 24 | 25 | TIMER_SECONDS = 0 26 | THROTTLE_RATES = {"test": "1/min"} 27 | 28 | 29 | class MockView(APIView): 30 | """Testing mock view.""" 31 | 32 | throttle_classes = (TestMonitoredScopedRateThrottle,) 33 | throttle_scope = "test" 34 | 35 | def get(self, request): 36 | """Return dummy response.""" 37 | return Response("foo") 38 | 39 | 40 | def test_monitored_scoped_rate_throttle(caplog): 41 | """Test the monitored scoped rate throttle.""" 42 | factory = APIRequestFactory() 43 | request = factory.get("/") 44 | for _ in range(4): 45 | response = MockView.as_view()(request) 46 | assert "Rate limit exceeded for scope test" in caplog.text 47 | for record in caplog.records: 48 | assert record.levelname == "WARNING" 49 | assert response.status_code == 429 50 | 51 | 52 | def test_monitored_scoped_rate_throttle_custom_callback(caplog, settings): 53 | """Test the monitored scoped rate throttle with a custom callback.""" 54 | settings.MONITORED_THROTTLE_FAILURE_CALLBACK = "tests.drf.test_throttling.custom_callback" 55 | factory = APIRequestFactory() 56 | request = factory.get("/") 57 | for _ in range(4): 58 | response = MockView.as_view()(request) 59 | assert "Rate limit exceeded for scope test" in caplog.text 60 | for record in caplog.records: 61 | assert record.levelname == "CRITICAL" 62 | assert response.status_code == 429 63 | -------------------------------------------------------------------------------- /src/lasuite/configuration/values.py: -------------------------------------------------------------------------------- 1 | """Custom value classes for django-configurations.""" 2 | 3 | import os 4 | 5 | from configurations import values 6 | 7 | 8 | class SecretFileValue(values.Value): 9 | """ 10 | Class used to interpret value from environment variables with reading file support. 11 | 12 | The value set is either (in order of priority): 13 | * The content of the file referenced by the environment variable 14 | `{name}_{file_suffix}` if set. 15 | * The value of the environment variable `{name}` if set. 16 | * The default value 17 | """ 18 | 19 | file_suffix = "FILE" 20 | 21 | def __init__(self, *args, **kwargs): 22 | """Initialize the value.""" 23 | super().__init__(*args, **kwargs) 24 | if "file_suffix" in kwargs: 25 | self.file_suffix = kwargs["file_suffix"] 26 | 27 | def setup(self, name): 28 | """Get the value from environment variables.""" 29 | value = self.default 30 | if self.environ: 31 | full_environ_name = self.full_environ_name(name) 32 | full_environ_name_file = f"{full_environ_name}_{self.file_suffix}" 33 | if full_environ_name_file in os.environ: 34 | filename = os.environ[full_environ_name_file] 35 | if not os.path.exists(filename): 36 | raise ValueError(f"Path {filename!r} does not exist.") 37 | try: 38 | with open(filename) as file: 39 | value = self.to_python(file.read().removesuffix("\n")) 40 | except (OSError, PermissionError) as err: 41 | raise ValueError(f"Path {filename!r} cannot be read: {err!r}") from err 42 | elif full_environ_name in os.environ: 43 | value = self.to_python(os.environ[full_environ_name]) 44 | elif self.environ_required: 45 | raise ValueError( 46 | f"Value {name!r} is required to be set as the " 47 | f"environment variable {full_environ_name_file!r} or {full_environ_name!r}" 48 | ) 49 | self.value = value 50 | return value 51 | -------------------------------------------------------------------------------- /documentation/how-to-use-marketing-backend.md: -------------------------------------------------------------------------------- 1 | # Using marketing backend 2 | 3 | If you need a marketing service in the application you are developping, this module will provide the implementation you need. 4 | 5 | The list of backends available and what they are able to do is subject to evolution. 6 | 7 | ## Global idea 8 | 9 | A backend should inherit from `lasuite.marketing.backends.base.BaseBackend` class and implement the abstract methods declared in it. 10 | 11 | ### Abstract methods 12 | 13 | - `def create_or_update_contact(self, contact_data: ContactData, timeout: int = None) -> dict:`: It is the method to call to create a new contact in your marketin system. 14 | - `contact_data` is an instance of the `lasuite.marketing.ContactData` data class. 15 | - `timeout` is an optional parameter used if you want to force a request timeout. 16 | 17 | ### How to use a backend 18 | 19 | We provide a handler responsible to instantiate and configure a backend. You have to declare in your settings the backend to use with their needed parameters. 20 | 21 | The settings to use is `settings.LASUITE_MARKETING`. It is a `dict` containing two keys: `BACKEND` and `PARAMETERS`. The `BACKEND` is the full path to the backend class and the `PARAMETERS` is a dict containing all the parameters needed to instantiate the backend class. 22 | 23 | Example: 24 | 25 | ```python 26 | settings.LASUITE_MARKETING = { 27 | "BACKEND": "lasuite.marketing.backends.dummy.DummyBackend", 28 | "PARAMETERS": {}, 29 | } 30 | ``` 31 | 32 | Then to use the backend in your code, you have to import the `lasuite.marketing.marketing` and call the `create_or_update_contact` method. 33 | 34 | ## Existing implementations 35 | 36 | ### Dummy 37 | 38 | path: `lasuite.marketing.backend.dummy.DummyBackend` 39 | 40 | This implementation does nothing and accept no parameter. 41 | 42 | ### Brevo 43 | 44 | path: ``lasuite.marketing.backend.brevo.BrevoBackend` 45 | parameters: 46 | - `api_key`: The api_key used by the brevo client. Provided by brevo. Required 47 | - `api_contact_list_ids`: The list of contact_list defined in brevo. At least one should be provided. Required 48 | - `api_contact_attributes`: A dict of attributes to add to the contact. Optional 49 | -------------------------------------------------------------------------------- /documentation/how-to-use-oidc-back-channel-logout.md: -------------------------------------------------------------------------------- 1 | # Using the OIDC Authentication Backend With Back-Channel Logout 2 | 3 | This guide explains how to integrate and configure the `OIDCBackChannelLogoutView` in your Django project for OpenID Connect (OIDC) authentication. 4 | 5 | ## Installation 6 | 7 | To use the OIDC authentication backend with back-channel logout support, who obviously need to have set up the OIDC authentication backend first. 8 | If you haven't done so, please refer to the [Using the OIDC Authentication Backend](how-to-use-oidc-backend.md) guide. 9 | 10 | 11 | ## Configuration 12 | 13 | ### Settings 14 | 15 | You need to have the [Using the OIDC Authentication Backend](how-to-use-oidc-backend.md) settings configured first. 16 | 17 | Then, add the following to your Django settings: 18 | 19 | ```python 20 | # A db backed session engine is required to support back-channel logout 21 | SESSION_ENGINE = "django.contrib.sessions.backends.cached_db" 22 | # OR "django.contrib.sessions.backends.db" if you don't want caching 23 | 24 | # New required OIDC settings 25 | OIDC_OP_URL="https://your-provider.com" 26 | ``` 27 | 28 | ### URLs 29 | 30 | The project OIDC URLs `lasuite.oidc_login.urls` already include the back-channel logout URL, 31 | so you just need to include them in your project's `urls.py` if you haven't done so already: 32 | 33 | ```python 34 | from django.urls import include, path 35 | 36 | urlpatterns = [ 37 | # Your other URLs 38 | path('', include('lasuite.oidc_login.urls')), 39 | ] 40 | ``` 41 | 42 | The back-channel logout endpoint will be available at `/back-channel-logout/`. 43 | 44 | 45 | ### Set up the OIDC Provider accordingly 46 | 47 | Make sure to configure your OIDC Provider to send back-channel logout requests 48 | to your Django application's back-channel logout endpoint. 49 | 50 | For instance, in Keycloak, you can set the "Backchannel Logout URL" in the client settings -> "Logout settings": 51 | 52 | - Turn off "Front channel logout" 53 | - Set "Backchannel Logout URL" to `/back-channel-logout/` (like http://app-dev:8071/api/v1.0/backchannel-logout/) 54 | - Enable/Disable "Backchannel Logout Session Required" as per your requirements 55 | 56 | Note the "Backchannel Logout Session Required" requires the `sid` claim to be sent in the token info at login to be 57 | able to match session. 58 | -------------------------------------------------------------------------------- /tests/oidc_resource_server/test_views.py: -------------------------------------------------------------------------------- 1 | """Tests for the Resource Server (RS) Views.""" 2 | 3 | from unittest import mock 4 | 5 | import pytest 6 | from django.core.exceptions import ImproperlyConfigured 7 | from django.urls import reverse 8 | from joserfc.jwk import RSAKey 9 | from rest_framework.test import APIClient 10 | 11 | pytestmark = pytest.mark.django_db 12 | 13 | 14 | @mock.patch("lasuite.oidc_resource_server.utils.import_private_key_from_settings") 15 | def test_view_jwks_valid_public_key(mock_import_private_key_from_settings): 16 | """JWKs endpoint should return a set of valid Json Web Key.""" 17 | mocked_key = RSAKey.generate_key(2048) 18 | mock_import_private_key_from_settings.return_value = mocked_key 19 | 20 | url = reverse("resource_server_jwks") 21 | response = APIClient().get(url) 22 | 23 | mock_import_private_key_from_settings.assert_called_once() 24 | 25 | assert response.status_code == 200 26 | assert response["Content-Type"] == "application/json" 27 | 28 | jwks = response.json() 29 | assert jwks == {"keys": [mocked_key.as_dict(private=False)]} 30 | 31 | # Security checks to make sure no details from the private key are exposed 32 | private_details = ["d", "p", "q", "dp", "dq", "qi", "oth", "r", "t"] 33 | assert all(private_detail not in jwks["keys"][0] for private_detail in private_details) 34 | 35 | 36 | @mock.patch("lasuite.oidc_resource_server.utils.import_private_key_from_settings") 37 | def test_view_jwks_invalid_private_key(mock_import_private_key_from_settings): 38 | """JWKS endpoint should return a proper exception when loading keys fails.""" 39 | mock_import_private_key_from_settings.return_value = "wrong_key" 40 | 41 | url = reverse("resource_server_jwks") 42 | response = APIClient().get(url) 43 | 44 | mock_import_private_key_from_settings.assert_called_once() 45 | 46 | assert response.status_code == 500 47 | assert response.json() == {"error": "Could not load key"} 48 | 49 | 50 | @mock.patch("lasuite.oidc_resource_server.utils.import_private_key_from_settings") 51 | def test_view_jwks_missing_private_key(mock_import_private_key_from_settings): 52 | """JWKS endpoint should return a proper exception when private key is missing.""" 53 | mock_import_private_key_from_settings.side_effect = ImproperlyConfigured("foo.") 54 | 55 | url = reverse("resource_server_jwks") 56 | response = APIClient().get(url) 57 | 58 | mock_import_private_key_from_settings.assert_called_once() 59 | 60 | assert response.status_code == 500 61 | assert response.json() == {"error": "foo."} 62 | -------------------------------------------------------------------------------- /src/lasuite/malware_detection/models.py: -------------------------------------------------------------------------------- 1 | """malware detection models.""" 2 | 3 | import json 4 | import uuid 5 | 6 | from django.db import models 7 | 8 | 9 | class MalwareDetectionStatus(models.TextChoices): 10 | """Malware detection status.""" 11 | 12 | PENDING = "pending", "Pending" 13 | PROCESSING = "processing", "Processing" 14 | FAILED = "failed", "Failed" 15 | 16 | 17 | class JsonUUIDEncoder(json.JSONEncoder): 18 | """JSON encoder for UUIDs.""" 19 | 20 | def default(self, obj): 21 | """Encode UUIDs.""" 22 | if isinstance(obj, uuid.UUID): 23 | return str(obj) 24 | return super().default(obj) 25 | 26 | 27 | class MalwareDetection(models.Model): 28 | """Malware detection model.""" 29 | 30 | id = models.UUIDField( 31 | help_text="primary key for the record as UUID", 32 | primary_key=True, 33 | default=uuid.uuid4, 34 | editable=False, 35 | ) 36 | created_at = models.DateTimeField( 37 | help_text="date and time at which a record was created", 38 | auto_now_add=True, 39 | editable=False, 40 | ) 41 | updated_at = models.DateTimeField( 42 | help_text="date and time at which a record was last updated", 43 | auto_now=True, 44 | editable=False, 45 | ) 46 | path = models.CharField( 47 | help_text="path to the file", 48 | max_length=255, 49 | unique=True, 50 | ) 51 | status = models.CharField( 52 | help_text="status of the detection", 53 | max_length=255, 54 | choices=MalwareDetectionStatus.choices, 55 | default=MalwareDetectionStatus.PENDING, 56 | ) 57 | parameters = models.JSONField( 58 | help_text="parameters for the detection", 59 | default=dict, 60 | null=True, 61 | blank=True, 62 | encoder=JsonUUIDEncoder, 63 | ) 64 | error_code = models.IntegerField( 65 | help_text="error code for the detection", 66 | blank=True, 67 | null=True, 68 | ) 69 | error_msg = models.TextField( 70 | help_text="error message for the detection", 71 | blank=True, 72 | ) 73 | backend = models.TextField( 74 | help_text="backend used for the detection", 75 | blank=True, 76 | ) 77 | file_hash = models.CharField( 78 | help_text="hash of the file", 79 | max_length=255, 80 | blank=True, 81 | ) 82 | 83 | def __str__(self): 84 | """Return a string representation of the model.""" 85 | return f"file {self.path} with status {self.status}" 86 | -------------------------------------------------------------------------------- /src/lasuite/malware_detection/tasks/jcop.py: -------------------------------------------------------------------------------- 1 | """Module containing the tasks for the JCOP backend.""" 2 | 3 | import logging 4 | from enum import IntEnum 5 | 6 | import requests 7 | from celery import shared_task 8 | 9 | from .. import MalwareDetectionHandler 10 | from ..exceptions import MalwareDetectionInvalidAuthenticationError 11 | 12 | logger = logging.getLogger(__name__) 13 | 14 | 15 | class MaxRetriesErrorCodes(IntEnum): 16 | """Error codes for max retries.""" 17 | 18 | TRIGGER_NEW_ANALYSIS = 9000 19 | TRIGGER_NEW_ANALYSIS_TIMEOUT = 9001 20 | ANALYSE_FILE = 9002 21 | 22 | 23 | @shared_task( 24 | bind=True, 25 | default_retry_delay=3, 26 | max_retries=100, 27 | dont_autoretry_for=(MalwareDetectionInvalidAuthenticationError,), 28 | ) 29 | def analyse_file_async( 30 | self, 31 | file_path: str, 32 | file_hash: str | None = None, 33 | **kwargs, 34 | ) -> None: 35 | """Task starting analysis process for a file.""" 36 | backend = MalwareDetectionHandler()() # JCOPBackend 37 | try: 38 | should_retry = backend.check_analysis(file_path, file_hash=file_hash, **kwargs) 39 | except requests.exceptions.RequestException as exc: 40 | if self.request.retries >= self.max_retries: 41 | backend.failed_analysis( 42 | file_path, 43 | error_code=MaxRetriesErrorCodes.ANALYSE_FILE, 44 | error_msg="Max retries fetching results exceeded", 45 | ) 46 | return 47 | self.retry(exc=exc) 48 | 49 | if should_retry: 50 | self.retry() 51 | 52 | 53 | @shared_task(bind=True, max_retries=6, dont_autoretry_for=(MalwareDetectionInvalidAuthenticationError,)) 54 | def trigger_new_analysis( 55 | self, 56 | file_path: str, 57 | file_hash: str, 58 | **kwargs, 59 | ) -> None: 60 | """Trigger a new analysis for a file.""" 61 | backend = MalwareDetectionHandler()() # JCOPBackend 62 | try: 63 | backend.trigger_new_analysis(file_path, file_hash, **kwargs) 64 | except requests.exceptions.RequestException as exc: 65 | if self.request.retries >= self.max_retries: 66 | backend.failed_analysis( 67 | file_path, 68 | error_code=MaxRetriesErrorCodes.TRIGGER_NEW_ANALYSIS, 69 | error_msg="Max retries triggering new analysis exceeded", 70 | ) 71 | return 72 | self.retry(exc=exc) 73 | return 74 | except TimeoutError: 75 | if self.request.retries >= self.max_retries: 76 | backend.failed_analysis( 77 | file_path, 78 | error_code=MaxRetriesErrorCodes.TRIGGER_NEW_ANALYSIS_TIMEOUT, 79 | error_msg="Max retries triggering new analysis exceeded", 80 | ) 81 | return 82 | self.retry(exc=TimeoutError()) 83 | return 84 | -------------------------------------------------------------------------------- /.gitlint: -------------------------------------------------------------------------------- 1 | # All these sections are optional, edit this file as you like. 2 | [general] 3 | # Ignore certain rules, you can reference them by their id or by their full name 4 | # ignore=title-trailing-punctuation, T3 5 | 6 | # verbosity should be a value between 1 and 3, the commandline -v flags take precedence over this 7 | # verbosity = 2 8 | 9 | # By default gitlint will ignore merge commits. Set to 'false' to disable. 10 | # ignore-merge-commits=true 11 | 12 | # By default gitlint will ignore fixup commits. Set to 'false' to disable. 13 | # ignore-fixup-commits=true 14 | 15 | # By default gitlint will ignore squash commits. Set to 'false' to disable. 16 | # ignore-squash-commits=true 17 | 18 | # Enable debug mode (prints more output). Disabled by default. 19 | # debug=true 20 | 21 | # Set the extra-path where gitlint will search for user defined rules 22 | # See http://jorisroovers.github.io/gitlint/user_defined_rules for details 23 | extra-path=gitlint/ 24 | 25 | # [title-max-length] 26 | # line-length=80 27 | 28 | [title-must-not-contain-word] 29 | # Comma-separated list of words that should not occur in the title. Matching is case 30 | # insensitive. It's fine if the keyword occurs as part of a larger word (so "WIPING" 31 | # will not cause a violation, but "WIP: my title" will. 32 | words=wip 33 | 34 | #[title-match-regex] 35 | # python like regex (https://docs.python.org/2/library/re.html) that the 36 | # commit-msg title must be matched to. 37 | # Note that the regex can contradict with other rules if not used correctly 38 | # (e.g. title-must-not-contain-word). 39 | #regex= 40 | 41 | # [B1] 42 | # B1 = body-max-line-length 43 | # line-length=120 44 | # [body-min-length] 45 | # min-length=5 46 | 47 | # [body-is-missing] 48 | # Whether to ignore this rule on merge commits (which typically only have a title) 49 | # default = True 50 | # ignore-merge-commits=false 51 | 52 | # [body-changed-file-mention] 53 | # List of files that need to be explicitly mentioned in the body when they are changed 54 | # This is useful for when developers often erroneously edit certain files or git submodules. 55 | # By specifying this rule, developers can only change the file when they explicitly reference 56 | # it in the commit message. 57 | # files=gitlint/rules.py,README.md 58 | 59 | # [author-valid-email] 60 | # python like regex (https://docs.python.org/2/library/re.html) that the 61 | # commit author email address should be matched to 62 | # For example, use the following regex if you only want to allow email addresses from foo.com 63 | # regex=[^@]+@foo.com 64 | 65 | [ignore-by-title] 66 | # Allow empty body & wrong title pattern only when bots (pyup/greenkeeper) 67 | # upgrade dependencies 68 | regex=^(⬆️.*|Update (.*) from (.*) to (.*)|(chore|fix)\(package\): update .*)$ 69 | ignore=B6,UC1 70 | 71 | # [ignore-by-body] 72 | # Ignore certain rules for commits of which the body has a line that matches a regex 73 | # E.g. Match bodies that have a line that that contain "release" 74 | # regex=(.*)release(.*) 75 | # 76 | # Ignore certain rules, you can reference them by their id or by their full name 77 | # Use 'all' to ignore all rules 78 | # ignore=T1,body-min-length 79 | -------------------------------------------------------------------------------- /tests/configuration/test_secret_file.py: -------------------------------------------------------------------------------- 1 | """Tests for SecretFileValue.""" 2 | 3 | import os 4 | 5 | import pytest 6 | 7 | from lasuite.configuration.values import SecretFileValue 8 | 9 | FILE_SECRET_PATH = os.path.join(os.path.dirname(os.path.realpath(__file__)), "test_secret") 10 | 11 | 12 | @pytest.fixture(autouse=True) 13 | def _mock_clear_env(monkeypatch): 14 | """Reset environment variables.""" 15 | monkeypatch.delenv("DJANGO_TEST_SECRET_KEY", raising=False) 16 | monkeypatch.delenv("DJANGO_TEST_SECRET_KEY_FILE", raising=False) 17 | monkeypatch.delenv("DJANGO_TEST_SECRET_KEY_PATH", raising=False) 18 | 19 | 20 | @pytest.fixture 21 | def _mock_secret_key_env(monkeypatch): 22 | """Set secret key in environment variable.""" 23 | monkeypatch.setenv("DJANGO_TEST_SECRET_KEY", "TestSecretInEnv") 24 | 25 | 26 | @pytest.fixture 27 | def _mock_secret_key_file_env(monkeypatch): 28 | """Set secret key path in environment variable.""" 29 | monkeypatch.setenv("DJANGO_TEST_SECRET_KEY_FILE", FILE_SECRET_PATH) 30 | 31 | 32 | @pytest.fixture 33 | def _mock_secret_key_path_env(monkeypatch): 34 | """Set secret key path in environment variable with another `file_suffix`.""" 35 | monkeypatch.setenv("DJANGO_TEST_SECRET_KEY_PATH", FILE_SECRET_PATH) 36 | 37 | 38 | def test_secret_default(): 39 | """Test call with no environment variable.""" 40 | value = SecretFileValue("DefaultTestSecret") 41 | assert value.setup("TEST_SECRET_KEY") == "DefaultTestSecret" 42 | 43 | 44 | @pytest.mark.usefixtures("_mock_secret_key_env") 45 | def test_secret_in_env(): 46 | """Test call with secret key environment variable.""" 47 | value = SecretFileValue("DefaultTestSecret") 48 | assert os.environ["DJANGO_TEST_SECRET_KEY"] == "TestSecretInEnv" 49 | assert value.setup("TEST_SECRET_KEY") == "TestSecretInEnv" 50 | 51 | 52 | @pytest.mark.usefixtures("_mock_secret_key_file_env") 53 | def test_secret_in_file(): 54 | """Test call with secret key file environment variable.""" 55 | value = SecretFileValue("DefaultTestSecret") 56 | assert os.environ["DJANGO_TEST_SECRET_KEY_FILE"] == FILE_SECRET_PATH 57 | assert value.setup("TEST_SECRET_KEY") == "TestSecretInFile" 58 | 59 | 60 | def test_secret_default_suffix(): 61 | """Test call with no environment variable and non default `file_suffix`.""" 62 | value = SecretFileValue("DefaultTestSecret", file_suffix="PATH") 63 | assert value.setup("TEST_SECRET_KEY") == "DefaultTestSecret" 64 | 65 | 66 | @pytest.mark.usefixtures("_mock_secret_key_env") 67 | def test_secret_in_env_suffix(): 68 | """Test call with secret key environment variable and non default `file_suffix`.""" 69 | value = SecretFileValue("DefaultTestSecret", file_suffix="PATH") 70 | assert os.environ["DJANGO_TEST_SECRET_KEY"] == "TestSecretInEnv" 71 | assert value.setup("TEST_SECRET_KEY") == "TestSecretInEnv" 72 | 73 | 74 | @pytest.mark.usefixtures("_mock_secret_key_path_env") 75 | def test_secret_in_file_suffix(): 76 | """Test call with secret key file environment variable and non default `file_suffix`.""" 77 | value = SecretFileValue("DefaultTestSecret", file_suffix="PATH") 78 | assert os.environ["DJANGO_TEST_SECRET_KEY_PATH"] == FILE_SECRET_PATH 79 | assert value.setup("TEST_SECRET_KEY") == "TestSecretInFile" 80 | -------------------------------------------------------------------------------- /tests/malware_detection/test_malware_detection_reschedule_processing_analysis_command.py: -------------------------------------------------------------------------------- 1 | """Test the malware detection reschedule processing analysis command.""" 2 | 3 | from datetime import datetime, timezone 4 | from unittest import mock 5 | 6 | import pytest 7 | from django.core.management import call_command 8 | from freezegun import freeze_time 9 | 10 | from lasuite.malware_detection.backends.base import BaseBackend 11 | from lasuite.malware_detection.models import MalwareDetection, MalwareDetectionStatus 12 | from tests import factories 13 | 14 | pytestmark = pytest.mark.django_db 15 | 16 | 17 | class TestBackend(BaseBackend): 18 | """Test backend.""" 19 | 20 | def launch_next_analysis(self) -> None: 21 | """Launch the next analysis.""" 22 | 23 | def analyse_file(self, file_path: str, **kwargs) -> None: 24 | """Analyse a file.""" 25 | 26 | def reschedule_processing_task(self, malware_detection_record: MalwareDetection) -> None: 27 | """Reschedule the processing task for a malware detection record.""" 28 | 29 | 30 | def test_malware_detection_reschedule_processing_analysis_command_no_processing_analysis(settings): 31 | """Test the malware detection reschedule processing analysis command with no processing analysis.""" 32 | settings.MALWARE_DETECTION = { 33 | "BACKEND": "tests.malware_detection.test_malware_detection_reschedule_processing_analysis_command.TestBackend", 34 | "PARAMETERS": { 35 | "callback_path": "tests.foo", 36 | }, 37 | } 38 | 39 | with mock.patch.object(TestBackend, "reschedule_processing_task") as mock_reschedule_processing_task: 40 | call_command("reschedule_processing_analysis") 41 | 42 | mock_reschedule_processing_task.assert_not_called() 43 | 44 | 45 | def test_malware_detection_reschedule_processing_analysis_command_processing_analysis(settings): 46 | """Test the malware detection reschedule processing analysis command with processing analysis.""" 47 | settings.MALWARE_DETECTION = { 48 | "BACKEND": "tests.malware_detection.test_malware_detection_reschedule_processing_analysis_command.TestBackend", 49 | "PARAMETERS": { 50 | "callback_path": "tests.foo", 51 | }, 52 | } 53 | 54 | with freeze_time(datetime(2025, 11, 15, 0, 0, 0, tzinfo=timezone.utc)): 55 | malware_detections_to_reschedule = factories.MalwareDetectionFactory.create_batch( 56 | 4, 57 | status=MalwareDetectionStatus.PROCESSING, 58 | ) 59 | 60 | with freeze_time(datetime(2025, 11, 19, 0, 0, 0, tzinfo=timezone.utc)): 61 | factories.MalwareDetectionFactory.create_batch( 62 | 4, 63 | status=MalwareDetectionStatus.PROCESSING, 64 | ) 65 | 66 | with ( 67 | freeze_time(datetime(2025, 11, 20, 0, 0, 0, tzinfo=timezone.utc)), 68 | mock.patch.object(TestBackend, "reschedule_processing_task") as mock_reschedule_processing_task, 69 | ): 70 | call_command("reschedule_processing_analysis") 71 | 72 | assert mock_reschedule_processing_task.call_count == 4 73 | calls = [mock.call(malware_detection) for malware_detection in malware_detections_to_reschedule] 74 | for call in calls: 75 | assert call in mock_reschedule_processing_task.call_args_list 76 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | # /!\ /!\ /!\ /!\ /!\ /!\ /!\ DISCLAIMER /!\ /!\ /!\ /!\ /!\ /!\ /!\ /!\ 2 | # 3 | # This Makefile is only meant to be used for DEVELOPMENT purpose as we are 4 | # changing the user id that will run in the container. 5 | # 6 | # PLEASE DO NOT USE IT FOR YOUR CI/PRODUCTION/WHATEVER... 7 | # 8 | # /!\ /!\ /!\ /!\ /!\ /!\ /!\ /!\ /!\ /!\ /!\ /!\ /!\ /!\ /!\ /!\ /!\ /!\ 9 | # 10 | # Note to developers: 11 | # 12 | # While editing this file, please respect the following statements: 13 | # 14 | # 1. Every variable should be defined in the ad hoc VARIABLES section with a 15 | # relevant subsection 16 | # 2. Every new rule should be defined in the ad hoc RULES section with a 17 | # relevant subsection depending on the targeted service 18 | # 3. Rules should be sorted alphabetically within their section 19 | # 4. When a rule has multiple dependencies, you should: 20 | # - duplicate the rule name to add the help string (if required) 21 | # - write one dependency per line to increase readability and diffs 22 | # 5. .PHONY rule statement should be written after the corresponding rule 23 | # ============================================================================== 24 | # VARIABLES 25 | 26 | 27 | 28 | BOLD := \033[1m 29 | RESET := \033[0m 30 | GREEN := \033[1;32m 31 | 32 | # Use uv for package management 33 | UV = uv 34 | 35 | # ============================================================================== 36 | # RULES 37 | 38 | default: help 39 | 40 | help: ## Display this help message 41 | @echo "$(BOLD)Django LaSuite Makefile" 42 | @echo "Please use 'make $(BOLD)target$(RESET)' where $(BOLD)target$(RESET) is one of:" 43 | @grep -E '^[a-zA-Z0-9_-]+:.*?## .*$$' $(firstword $(MAKEFILE_LIST)) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "$(GREEN)%-30s$(RESET) %s\n", $$1, $$2}' 44 | .PHONY: help 45 | 46 | install: ## Install the project 47 | @$(UV) sync 48 | .PHONY: install 49 | 50 | install-dev: ## Install the project with dev dependencies 51 | @$(UV) sync --extra dev 52 | .PHONY: install-dev 53 | 54 | install-build: ## Install the project with build dependencies 55 | @$(UV) sync --extra build 56 | 57 | clean: ## Clean the project folder 58 | @rm -rf build/ 59 | @rm -rf dist/ 60 | @rm -rf *.egg-info 61 | @find . -type d -name __pycache__ -exec rm -rf {} + 62 | @find . -type f -name "*.pyc" -delete 63 | .PHONY: clean 64 | 65 | format: ## Run the formatter 66 | @$(UV) run ruff format 67 | .PHONY: format 68 | 69 | lint: format ## Run the linter 70 | @$(UV) run ruff check --fix . 71 | .PHONY: lint 72 | 73 | test: ## Run the tests 74 | @cd tests && PYTHON_PATH=.:$(PYTHON_PATH) DJANGO_SETTINGS_MODULE=test_project.settings $(UV) run python -m pytest . -vvv 75 | .PHONY: test 76 | 77 | build: install-build ## Build the project 78 | @$(UV) build 79 | .PHONY: build 80 | 81 | migrate: ## Run the test project migrations 82 | @cd tests && $(UV) run python -m test_project.manage migrate 83 | .PHONY: migrate 84 | 85 | makemigrations: ## Run the test project migrations 86 | @cd tests && $(UV) run python -m test_project.manage makemigrations 87 | .PHONY: makemigrations 88 | 89 | runserver: ## Run the test project server 90 | @cd tests && $(UV) run python -m test_project.manage runserver 91 | .PHONY: runserver 92 | 93 | shell: ## Run the test project Django shell 94 | @cd tests && $(UV) run python -m test_project.manage shell 95 | .PHONY: shell 96 | -------------------------------------------------------------------------------- /src/lasuite/marketing/backends/brevo.py: -------------------------------------------------------------------------------- 1 | """Brevo marketing automation integration.""" 2 | 3 | import logging 4 | from urllib.parse import quote_plus 5 | 6 | import requests 7 | 8 | from lasuite.marketing.backends import ContactData 9 | from lasuite.marketing.exceptions import ContactCreationError 10 | 11 | from .base import BaseBackend 12 | 13 | logger = logging.getLogger(__name__) 14 | 15 | 16 | class BrevoBackend(BaseBackend): 17 | """ 18 | Brevo marketing automation integration. 19 | 20 | Handles: 21 | - Contact management and segmentation 22 | - Marketing campaigns and automation 23 | - Email communications 24 | """ 25 | 26 | def __init__(self, api_key: str, api_contact_list_ids: list[int], api_contact_attributes: dict | None = None): 27 | """Configure the Brevo backend.""" 28 | self._api_key = api_key 29 | self.api_contact_attributes = api_contact_attributes or {} 30 | self.api_contact_list_ids = api_contact_list_ids 31 | 32 | def create_or_update_contact(self, contact_data: ContactData, timeout: int = None) -> dict: 33 | """ 34 | Create or update a Brevo contact. 35 | 36 | Args: 37 | contact_data: Contact information and attributes 38 | timeout: API request timeout in seconds 39 | 40 | Returns: 41 | dict: Brevo API response 42 | 43 | Raises: 44 | ContactCreationError: If contact creation fails 45 | ImproperlyConfigured: If required settings are missing 46 | 47 | Note: 48 | Contact attributes must be pre-configured in Brevo. 49 | Changes to attributes can impact existing workflows. 50 | 51 | """ 52 | # First try to retrieve the contact by email 53 | try: 54 | email = quote_plus(contact_data.email) 55 | url = f"https://api.brevo.com/v3/contacts/{email}" 56 | response = requests.get( 57 | url, params={"identifierType": "email_id"}, headers={"api-key": self._api_key}, timeout=timeout or 10 58 | ) 59 | response.raise_for_status() 60 | contact = response.json() 61 | except requests.RequestException: 62 | pass 63 | else: 64 | # Add the list_ids from the contact in the contact_data 65 | list_ids = contact.get("listIds", []) 66 | contact_data.list_ids = (contact_data.list_ids or []) + list_ids 67 | 68 | attributes = { 69 | **self.api_contact_attributes, 70 | **(contact_data.attributes or {}), 71 | } 72 | 73 | # Use a set to avoid duplicates 74 | list_ids = set((contact_data.list_ids or []) + self.api_contact_list_ids) 75 | 76 | payload = { 77 | "email": contact_data.email, 78 | "updateEnabled": contact_data.update_enabled, 79 | "listIds": list(list_ids), 80 | "attributes": attributes, 81 | } 82 | 83 | print(payload) 84 | 85 | try: 86 | response = requests.post( 87 | "https://api.brevo.com/v3/contacts", 88 | json=payload, 89 | headers={"api-key": self._api_key}, 90 | timeout=timeout or 10, 91 | ) 92 | response.raise_for_status() 93 | except requests.RequestException as err: 94 | raise ContactCreationError("Failed to create contact in Brevo") from err 95 | 96 | if response.status_code == requests.codes.created: 97 | return response.json() 98 | 99 | return {} 100 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["hatchling"] 3 | build-backend = "hatchling.build" 4 | 5 | [project] 6 | name = "django-lasuite" 7 | version = "0.0.22" 8 | description = "Django La Suite - A Django library" 9 | readme = "README.md" 10 | requires-python = ">=3.10" 11 | license = {file = "LICENSE"} 12 | authors = [ 13 | {name = "DINUM", email = "dev@mail.numerique.gouv.fr"}, 14 | ] 15 | classifiers = [ 16 | "Development Status :: 3 - Alpha", 17 | "Framework :: Django", 18 | "Framework :: Django :: 4.2", 19 | "Framework :: Django :: 5.0", 20 | "Framework :: Django :: 5.1", 21 | "Framework :: Django :: 5.2", 22 | "Intended Audience :: Developers", 23 | "License :: OSI Approved :: MIT License", 24 | "Programming Language :: Python :: 3", 25 | "Programming Language :: Python :: 3.11", 26 | "Programming Language :: Python :: 3.12", 27 | "Programming Language :: Python :: 3.13", 28 | "Programming Language :: Python :: 3.14", 29 | ] 30 | dependencies = [ 31 | "django>=5.0", 32 | "djangorestframework>=3.15.2", 33 | "mozilla-django-oidc>=4.0.1", 34 | "joserfc>=1.4.0", 35 | "requests>=2.32.3", 36 | "requests-toolbelt>=1.0.0", 37 | ] 38 | 39 | [project.urls] 40 | "Homepage" = "https://github.com/suitenumerique/django-lasuite" 41 | "Bug Tracker" = "https://github.com/suitenumerique/django-lasuite/issues" 42 | 43 | [project.optional-dependencies] 44 | build = [ 45 | "setuptools", 46 | "wheel", 47 | ] 48 | dev = [ 49 | "factory_boy", 50 | "pytest", 51 | "pytest-django", 52 | "responses", 53 | "ruff", 54 | "django-lasuite[all]", 55 | "freezegun>=1.5.5", 56 | ] 57 | malware_detection = [ 58 | "celery>=5.0", 59 | ] 60 | configuration = [ 61 | "django-configurations>=2.5.1", 62 | ] 63 | all=[ 64 | "django-lasuite[malware_detection]", 65 | "django-lasuite[configuration]", 66 | ] 67 | 68 | [tool.hatch.build.targets.sdist] 69 | only-include = ["src"] 70 | 71 | [tool.hatch.build.targets.wheel] 72 | packages = ["src/lasuite"] 73 | 74 | [tool.pytest.ini_options] 75 | python_files = "test_*.py" 76 | testpaths = ["tests"] 77 | 78 | [tool.ruff] 79 | line-length = 120 80 | target-version = "py310" 81 | extend-exclude = ["migrations"] 82 | lint.select = [ 83 | # pycodestyle 84 | "E", "W", 85 | # Pyflakes 86 | "F", 87 | # pyupgrade 88 | "UP", 89 | # flake8-bugbear 90 | "B", 91 | # flake8-simplify 92 | "SIM", 93 | # isort 94 | "I", 95 | # flake8-logging-format 96 | "G", 97 | # flake8-pie 98 | "PIE", 99 | # flake8-comprehensions 100 | "C4", 101 | # flake8-django 102 | "DJ", 103 | # flake8-bandit 104 | "S", 105 | # flake8-builtins 106 | "A", 107 | # flake8-datetimez 108 | "DTZ", 109 | # flake8-gettext 110 | "INT", 111 | # Pylint 112 | "PL", 113 | # flake8-fixme 114 | "FIX", 115 | # flake8-self 116 | "SLF", 117 | # flake8-return 118 | "RET", 119 | # pep8-naming (N) 120 | "N", 121 | # pydocstyle 122 | "D", 123 | # flake8-pytest-style (PT) 124 | "PT", 125 | ] 126 | lint.ignore = [ 127 | # incorrect-blank-line-before-class 128 | "D203", 129 | # missing-blank-line-after-summary 130 | "D205", 131 | # multi-line-summary-first-line 132 | "D212", 133 | ] 134 | lint.per-file-ignores = { "**/tests/*"= [ 135 | # flake8-bandit 136 | "S", 137 | # flake8-self 138 | "SLF", 139 | # magic-value-comparison 140 | "PLR2004", 141 | ], "__init__.py"= [ 142 | # Missing docstring in public package 143 | "D104" 144 | ]} 145 | 146 | 147 | 148 | [tool.ruff.lint.isort] 149 | known-first-party = ["lasuite"] 150 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: Test Workflow 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | branches: 9 | - '*' 10 | 11 | permissions: 12 | checks: write 13 | 14 | jobs: 15 | 16 | lint-git: 17 | runs-on: ubuntu-latest 18 | if: github.event_name == 'pull_request' # Makes sense only for pull requests 19 | steps: 20 | - name: Checkout repository 21 | uses: actions/checkout@v5 22 | with: 23 | fetch-depth: 0 24 | - name: show 25 | run: git log 26 | - name: Check absence of fixup commits 27 | run: | 28 | ! git log | grep 'fixup!' 29 | - name: Install gitlint 30 | run: pip install --user requests gitlint 31 | - name: Lint commit messages added to main 32 | run: ~/.local/bin/gitlint --commits origin/${{ github.event.pull_request.base.ref }}..HEAD 33 | 34 | check-changelog: 35 | runs-on: ubuntu-latest 36 | if: | 37 | contains(github.event.pull_request.labels.*.name, 'noChangeLog') == false && 38 | github.event_name == 'pull_request' 39 | steps: 40 | - name: Checkout repository 41 | uses: actions/checkout@v5 42 | with: 43 | fetch-depth: 0 44 | - name: Check that the CHANGELOG has been modified in the current branch 45 | run: git log --name-only --pretty="" origin/${{ github.event.pull_request.base.ref }}..HEAD | grep CHANGELOG 46 | 47 | lint-changelog: 48 | runs-on: ubuntu-latest 49 | steps: 50 | - name: Checkout repository 51 | uses: actions/checkout@v5 52 | - name: Check CHANGELOG max line length 53 | run: | 54 | max_line_length=$(cat CHANGELOG.md | grep -Ev "^\[.*\]: https://github.com" | wc -L) 55 | if [ $max_line_length -ge 80 ]; then 56 | echo "ERROR: CHANGELOG has lines longer than 80 characters." 57 | exit 1 58 | fi 59 | 60 | 61 | lint-back: 62 | runs-on: ubuntu-latest 63 | steps: 64 | - name: Checkout repository 65 | uses: actions/checkout@v5 66 | - name: Install Python 67 | uses: actions/setup-python@v6 68 | with: 69 | python-version: '3.14' 70 | - name: Install development dependencies 71 | run: pip install --user .[dev] 72 | - name: Check code formatting with ruff 73 | run: ~/.local/bin/ruff format . --diff 74 | - name: Lint code with ruff 75 | run: ~/.local/bin/ruff check . 76 | 77 | test-pytest: 78 | name: test with ${{ matrix.python }} 79 | runs-on: ubuntu-latest 80 | strategy: 81 | fail-fast: false 82 | matrix: 83 | python: 84 | - "3.11" 85 | - "3.12" 86 | - "3.13" 87 | - "3.14" 88 | 89 | steps: 90 | - uses: actions/checkout@v5 91 | with: 92 | fetch-depth: 0 93 | - name: Install the latest version of uv 94 | uses: astral-sh/setup-uv@v7 95 | with: 96 | enable-cache: true 97 | cache-dependency-glob: "pyproject.toml" 98 | github-token: ${{ secrets.GITHUB_TOKEN }} 99 | - name: Install tox 100 | run: uv tool install --python-preference only-managed --python 3.13 tox --with tox-uv --with tox-gh 101 | - name: Install Python 102 | if: matrix.python != '3.13' 103 | run: uv python install --python-preference only-managed ${{ matrix.python }} 104 | - name: Setup test suite 105 | run: tox run -vv --notest --skip-missing-interpreters false 106 | env: 107 | TOX_GH_MAJOR_MINOR: ${{ matrix.python }} 108 | - name: Run test suite 109 | run: tox run --skip-pkg-install 110 | env: 111 | TOX_GH_MAJOR_MINOR: ${{ matrix.python }} 112 | - name: Test Report 113 | uses: dorny/test-reporter@v2 114 | if: success() || failure() 115 | with: 116 | name: Test Results (${{ matrix.python }}) 117 | path: .tox/py*/junit.xml 118 | reporter: java-junit 119 | -------------------------------------------------------------------------------- /src/lasuite/drf/models/choices.py: -------------------------------------------------------------------------------- 1 | """Declare and configure choices for application managing accesses.""" 2 | 3 | from django.db.models import TextChoices 4 | from django.utils.translation import gettext_lazy as _ 5 | 6 | 7 | class PriorityTextChoices(TextChoices): 8 | """ 9 | Class inherits from Django's TextChoices and provides a method to get the priority 10 | of a given value based on its position in the class. 11 | """ 12 | 13 | @classmethod 14 | def get_priority(cls, role): 15 | """Return the priority of the given role based on its order in the class.""" 16 | members = list(cls.__members__.values()) 17 | return members.index(role) + 1 if role in members else 0 18 | 19 | @classmethod 20 | def max(cls, *roles): 21 | """ 22 | Return the highest-priority role among the given roles, using get_priority(). 23 | If no valid roles are provided, returns None. 24 | """ 25 | valid_roles = [role for role in roles if cls.get_priority(role) is not None] 26 | if not valid_roles: 27 | return None 28 | return max(valid_roles, key=cls.get_priority) 29 | 30 | 31 | class LinkRoleChoices(PriorityTextChoices): 32 | """Define the possible roles a link can offer on a document.""" 33 | 34 | READER = "reader", _("Reader") # Can read 35 | EDITOR = "editor", _("Editor") # Can read and edit 36 | 37 | 38 | class RoleChoices(PriorityTextChoices): 39 | """Define the possible roles a user can have in a resource.""" 40 | 41 | READER = "reader", _("Reader") # Can read 42 | EDITOR = "editor", _("Editor") # Can read and edit 43 | ADMIN = "administrator", _("Administrator") # Can read, edit, delete and share 44 | OWNER = "owner", _("Owner") 45 | 46 | 47 | PRIVILEGED_ROLES = [RoleChoices.ADMIN, RoleChoices.OWNER] 48 | 49 | 50 | class LinkReachChoices(PriorityTextChoices): 51 | """Define types of access for links.""" 52 | 53 | RESTRICTED = ( 54 | "restricted", 55 | _("Restricted"), 56 | ) # Only users with a specific access can read/edit the document 57 | AUTHENTICATED = ( 58 | "authenticated", 59 | _("Authenticated"), 60 | ) # Any authenticated user can access the document 61 | PUBLIC = "public", _("Public") # Even anonymous users can access the document 62 | 63 | @classmethod 64 | def get_select_options(cls, link_reach, link_role): 65 | """ 66 | Determine the valid select options for link reach and link role depending on the 67 | ancestors' link reach/role given as arguments. 68 | 69 | Returns: 70 | Dictionary mapping possible reach levels to their corresponding possible roles. 71 | 72 | """ 73 | return { 74 | reach: [ 75 | role 76 | for role in LinkRoleChoices.values 77 | if LinkRoleChoices.get_priority(role) >= LinkRoleChoices.get_priority(link_role) 78 | ] 79 | if reach != cls.RESTRICTED 80 | else None 81 | for reach in cls.values 82 | if LinkReachChoices.get_priority(reach) >= LinkReachChoices.get_priority(link_reach) 83 | } 84 | 85 | 86 | def get_equivalent_link_definition(ancestors_links): 87 | """ 88 | Return the (reach, role) pair. 89 | 1. Highest reach. 90 | 2. Highest role among links having that reach. 91 | """ 92 | if not ancestors_links: 93 | return {"link_reach": None, "link_role": None} 94 | 95 | # 1) Find the highest reach 96 | max_reach = max( 97 | ancestors_links, 98 | key=lambda link: LinkReachChoices.get_priority(link["link_reach"]), 99 | )["link_reach"] 100 | 101 | # 2) Among those, find the highest role (ignore role if RESTRICTED) 102 | if max_reach == LinkReachChoices.RESTRICTED: 103 | max_role = None 104 | else: 105 | max_role = max( 106 | (link["link_role"] for link in ancestors_links if link["link_reach"] == max_reach), 107 | key=LinkRoleChoices.get_priority, 108 | ) 109 | 110 | return {"link_reach": max_reach, "link_role": max_role} 111 | -------------------------------------------------------------------------------- /tests/oidc_resource_server/test_authentication_get_access_token.py: -------------------------------------------------------------------------------- 1 | """Tests for ResourceServerAuthentication.get_access_token method.""" 2 | 3 | import base64 4 | from unittest.mock import patch 5 | 6 | import pytest 7 | from django.test import RequestFactory 8 | 9 | from lasuite.oidc_resource_server.authentication import ResourceServerAuthentication 10 | 11 | 12 | @pytest.fixture(autouse=True) 13 | def common_settings_fixture(settings): 14 | """Fixture to set up common settings for tests.""" 15 | settings.OIDC_RS_CLIENT_ID = "some_client_id" 16 | settings.OIDC_RS_CLIENT_SECRET = "some_client_secret" 17 | settings.OIDC_OP_URL = "https://oidc.example.com" 18 | settings.OIDC_VERIFY_SSL = False 19 | settings.OIDC_TIMEOUT = 5 20 | settings.OIDC_PROXY = None 21 | settings.OIDC_OP_INTROSPECTION_ENDPOINT = "https://oidc.example.com/introspect" 22 | 23 | 24 | def test_get_access_token_regular_token(): 25 | """Test retrieving a regular non-encoded token from Authorization header.""" 26 | # Given a request with a regular token 27 | token = "regular_token_string" 28 | request = RequestFactory().get("/") 29 | request.META = {"HTTP_AUTHORIZATION": f"Bearer {token}"} 30 | 31 | # When get_access_token is called 32 | result = ResourceServerAuthentication().get_access_token(request) 33 | 34 | # Then the token is returned as-is 35 | assert result == token 36 | 37 | 38 | def test_get_access_token_base64_encoded(): 39 | """Test retrieving a base64-encoded token from Authorization header.""" 40 | # Given a request with a base64-encoded token 41 | original_token = "original_token_string" 42 | encoded_token = base64.b64encode(original_token.encode("utf-8")).decode("utf-8") 43 | request = RequestFactory().get("/") 44 | request.META = {"HTTP_AUTHORIZATION": f"Bearer {encoded_token}"} 45 | 46 | # When get_access_token is called 47 | result = ResourceServerAuthentication().get_access_token(request) 48 | 49 | # Then the token is decoded 50 | assert result == original_token 51 | 52 | 53 | def test_get_access_token_jwt_like_token(): 54 | """Test retrieving a regular non-encoded JWT like token from Authorization header.""" 55 | # Given a request with a regular token 56 | token = "eyJhbGciOiJS.eyJhbGciOiJS.UmwJQPqqaK4o" 57 | request = RequestFactory().get("/") 58 | request.META = {"HTTP_AUTHORIZATION": f"Bearer {token}"} 59 | 60 | # When get_access_token is called 61 | result = ResourceServerAuthentication().get_access_token(request) 62 | 63 | # Then the token is returned as-is 64 | assert result == token 65 | 66 | 67 | def test_get_access_token_invalid_base64(): 68 | """Test retrieving an invalid base64 token returns original token.""" 69 | # Given a request with an invalid base64 token 70 | invalid_base64 = "invalid-base64!@#$" 71 | request = RequestFactory().get("/") 72 | request.META = {"HTTP_AUTHORIZATION": f"Bearer {invalid_base64}"} 73 | 74 | # When get_access_token is called 75 | result = ResourceServerAuthentication().get_access_token(request) 76 | 77 | # Then the original token is returned 78 | assert result == invalid_base64 79 | 80 | 81 | def test_get_access_token_no_auth_header(): 82 | """Test behavior when no Authorization header is present.""" 83 | # Given a request with no Authorization header 84 | request = RequestFactory().get("/") 85 | request.META = {} 86 | 87 | assert ResourceServerAuthentication().get_access_token(request) is None 88 | 89 | 90 | @patch("mozilla_django_oidc.contrib.drf.OIDCAuthentication.get_access_token") 91 | def test_get_access_token_parent_method_called(mock_parent_method): 92 | """Test that parent class method is called correctly.""" 93 | # Given a request and a mocked parent method 94 | token = "test_token" 95 | mock_parent_method.return_value = token 96 | request = RequestFactory().get("/") 97 | 98 | # When get_access_token is called 99 | result = ResourceServerAuthentication().get_access_token(request) 100 | 101 | # Then parent method is called with request 102 | mock_parent_method.assert_called_once_with(request) 103 | assert result == token 104 | -------------------------------------------------------------------------------- /documentation/how-to-use-oidc-resource-server-backend.md: -------------------------------------------------------------------------------- 1 | # Using the OIDC Resource Server Backend 2 | 3 | This guide explains how to integrate and configure the `ResourceServerBackend` in your Django project for secure API access using OpenID Connect (OIDC) token introspection. 4 | 5 | ## Overview 6 | 7 | The `ResourceServerBackend` allows your application to act as an OAuth 2.0 resource server, validating access tokens through introspection with an authorization server. This enables secure API access control using OIDC standards. 8 | 9 | ## Installation 10 | 11 | 1. Ensure you have the necessary packages installed: 12 | 13 | ```bash 14 | pip install django-lasuite 15 | ``` 16 | 17 | ## Configuration 18 | 19 | ### Settings 20 | 21 | Add the following to your Django settings: 22 | 23 | ```python 24 | # Resource Server Backend 25 | OIDC_RS_BACKEND_CLASS = "lasuite.oidc_resource_server.backend.ResourceServerBackend" 26 | 27 | # Resource Server Configuration 28 | OIDC_RS_AUDIENCE_CLAIM = "client_id" # The claim used to identify the audience 29 | OIDC_RS_ENCRYPTION_ENCODING = "A256GCM" # Encryption encoding algorithm 30 | OIDC_RS_ENCRYPTION_ALGO = "RSA-OAEP" # Encryption algorithm 31 | OIDC_RS_SIGNING_ALGO = "ES256" # Signing algorithm 32 | OIDC_RS_SCOPES = ["groups"] # Required scopes for authentication 33 | 34 | # Private key for encryption/decryption 35 | OIDC_RS_PRIVATE_KEY_STR = """-----BEGIN PRIVATE KEY----- 36 | YOUR_PRIVATE_KEY_HERE 37 | -----END PRIVATE KEY-----""" 38 | OIDC_RS_ENCRYPTION_KEY_TYPE = "RSA" # Key type (RSA, EC, etc.) 39 | 40 | # Client credentials 41 | OIDC_RP_CLIENT_ID = "your-client-id" 42 | OIDC_RP_CLIENT_SECRET = "your-client-secret" 43 | 44 | # Authorization server endpoints 45 | OIDC_OP_TOKEN_ENDPOINT = "https://your-provider.com/token" 46 | OIDC_OP_USER_ENDPOINT = "https://your-provider.com/userinfo" 47 | OIDC_OP_INTROSPECTION_ENDPOINT = "https://your-provider.com/token/introspect" 48 | OIDC_OP_USER_ENDPOINT_FORMAT = "AUTO" # AUTO, JSON, or JWT 49 | ``` 50 | 51 | ### URLs Configuration 52 | 53 | Include the OIDC Resource Server URLs in your project's `urls.py`: 54 | 55 | ```python 56 | from django.urls import include, path 57 | 58 | urlpatterns = [ 59 | # Your other URLs 60 | path('', include('lasuite.oidc_resource_server.urls')), 61 | ] 62 | ``` 63 | 64 | This will expose the JWKS endpoint (`/jwks`) which provides the public key used for token verification. 65 | 66 | ## Usage in Views 67 | 68 | To secure your API views, use the authorization backend with Django REST Framework: 69 | 70 | ```python 71 | from rest_framework.permissions import IsAuthenticated 72 | from rest_framework.response import Response 73 | from rest_framework.views import APIView 74 | 75 | from lasuite.oidc_resource_server.authentication import ResourceServerAuthentication 76 | 77 | class SecureAPIView(APIView): 78 | authentication_classes = [ResourceServerAuthentication] 79 | permission_classes = [IsAuthenticated] 80 | 81 | def get(self, request): 82 | # Your secure view logic here 83 | return Response({"message": "Authenticated access"}) 84 | ``` 85 | 86 | ## Token Verification Flow 87 | 88 | 1. Client sends request with access token in Authorization header 89 | 2. `ResourceServerBackend` intercepts the request 90 | 3. Backend sends token to authorization server for introspection 91 | 4. Backend validates returned claims (issuer, audience, etc.) 92 | 5. If valid, request is processed; otherwise, authentication fails 93 | 94 | ## Advanced: JWT Resource Server 95 | 96 | For JWT-based introspection (RFC 9701), use the `JWTResourceServerBackend`: 97 | 98 | ```python 99 | OIDC_RS_BACKEND_CLASS = "lasuite.oidc_resource_server.backend.JWTResourceServerBackend" 100 | ``` 101 | 102 | This implementation handles JWT format introspection responses that are signed and encrypted, providing an additional layer of security. 103 | 104 | ## Key Management 105 | 106 | The resource server requires a key pair: 107 | - The private key is used for decryption and stored securely in your settings 108 | - The public key is exposed via the JWKS endpoint for the authorization server 109 | 110 | Generate a suitable RSA key, like using OpenSSL: 111 | 112 | ```bash 113 | openssl genrsa -out private_key.pem 2048 114 | ``` 115 | -------------------------------------------------------------------------------- /tests/test_project/settings.py: -------------------------------------------------------------------------------- 1 | """Django settings for test project.""" 2 | 3 | from pathlib import Path 4 | 5 | # Build paths inside the project like this: BASE_DIR / 'subdir'. 6 | BASE_DIR = Path(__file__).resolve().parent.parent.parent 7 | 8 | # SECURITY WARNING: keep the secret key used in production secret! 9 | SECRET_KEY = "django-insecure-test-key-for-development-only" # noqa: S105 10 | 11 | # SECURITY WARNING: don't run with debug turned on in production! 12 | DEBUG = True 13 | 14 | ALLOWED_HOSTS = ["*"] 15 | 16 | # Application definition 17 | INSTALLED_APPS = [ 18 | "django.contrib.admin", 19 | "django.contrib.auth", 20 | "django.contrib.contenttypes", 21 | "django.contrib.sessions", 22 | "django.contrib.messages", 23 | "django.contrib.staticfiles", 24 | "lasuite.malware_detection", 25 | "test_project.user", 26 | ] 27 | 28 | MIDDLEWARE = [ 29 | "django.middleware.security.SecurityMiddleware", 30 | "django.contrib.sessions.middleware.SessionMiddleware", 31 | "django.middleware.common.CommonMiddleware", 32 | "django.middleware.csrf.CsrfViewMiddleware", 33 | "django.contrib.auth.middleware.AuthenticationMiddleware", 34 | "django.contrib.messages.middleware.MessageMiddleware", 35 | "django.middleware.clickjacking.XFrameOptionsMiddleware", 36 | ] 37 | 38 | ROOT_URLCONF = "test_project.urls" 39 | 40 | TEMPLATES = [ 41 | { 42 | "BACKEND": "django.template.backends.django.DjangoTemplates", 43 | "DIRS": [], 44 | "APP_DIRS": True, 45 | "OPTIONS": { 46 | "context_processors": [ 47 | "django.template.context_processors.debug", 48 | "django.template.context_processors.request", 49 | "django.contrib.auth.context_processors.auth", 50 | "django.contrib.messages.context_processors.messages", 51 | ], 52 | }, 53 | }, 54 | ] 55 | 56 | WSGI_APPLICATION = "test_project.wsgi.application" 57 | 58 | # Database 59 | DATABASES = { 60 | "default": { 61 | "ENGINE": "django.db.backends.sqlite3", 62 | "NAME": BASE_DIR / "db.sqlite3", 63 | } 64 | } 65 | 66 | # Storage 67 | STORAGES = { 68 | "default": { 69 | "BACKEND": "django.core.files.storage.InMemoryStorage", 70 | }, 71 | "staticfiles": { 72 | "BACKEND": "django.contrib.staticfiles.storage.StaticFilesStorage", 73 | }, 74 | } 75 | 76 | # Internationalization 77 | LANGUAGE_CODE = "en-us" 78 | TIME_ZONE = "UTC" 79 | USE_I18N = True 80 | USE_TZ = True 81 | 82 | # Static files (CSS, JavaScript, Images) 83 | STATIC_URL = "/static/" 84 | 85 | # Default primary key field type 86 | DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" 87 | 88 | # Logging Configuration 89 | LOGGING = { 90 | "version": 1, 91 | "disable_existing_loggers": False, 92 | "formatters": { 93 | "verbose": { 94 | "format": "{levelname} {asctime} {module} {message}", 95 | "style": "{", 96 | }, 97 | }, 98 | "handlers": { 99 | "console": { 100 | "class": "logging.StreamHandler", 101 | "formatter": "verbose", 102 | }, 103 | }, 104 | "root": { 105 | "handlers": ["console"], 106 | "level": "INFO", 107 | }, 108 | } 109 | 110 | 111 | # Test variables 112 | AUTH_USER_MODEL = "user.User" 113 | 114 | AUTHENTICATION_BACKENDS = [ 115 | "lasuite.oidc_login.backends.OIDCAuthenticationBackend", 116 | ] 117 | 118 | # - OIDC module 119 | OIDC_AUTHENTICATE_CLASS = "lasuite.oidc_login.views.OIDCAuthenticationRequestView" 120 | OIDC_CALLBACK_CLASS = "lasuite.oidc_login.views.OIDCAuthenticationCallbackView" 121 | 122 | OIDC_OP_TOKEN_ENDPOINT = None 123 | OIDC_OP_USER_ENDPOINT = None 124 | OIDC_OP_LOGOUT_ENDPOINT = None 125 | OIDC_OP_AUTHORIZATION_ENDPOINT = None 126 | OIDC_RP_CLIENT_ID = "lasuite" 127 | OIDC_RP_CLIENT_SECRET = "lasuite" 128 | OIDC_USERINFO_FULLNAME_FIELDS = ["first_name", "last_name"] 129 | OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION = True 130 | 131 | # - OIDC resource server module 132 | OIDC_RS_AUDIENCE_CLAIM = "client_id" 133 | OIDC_RS_BACKEND_CLASS = "lasuite.oidc_resource_server.backend.ResourceServerBackend" 134 | OIDC_RS_ENCRYPTION_ENCODING = "A256GCM" 135 | OIDC_RS_ENCRYPTION_ALGO = "RSA-OAEP" 136 | OIDC_RS_SIGNING_ALGO = "ES256" 137 | OIDC_RS_SCOPES = ["groups"] 138 | -------------------------------------------------------------------------------- /documentation/how-to-use-oidc-backend.md: -------------------------------------------------------------------------------- 1 | # Using the OIDC Authentication Backend 2 | 3 | This guide explains how to integrate and configure the `OIDCAuthenticationBackend` in your Django project for OpenID Connect (OIDC) authentication. 4 | 5 | ## Installation 6 | 7 | 1. Ensure you have the necessary packages installed: 8 | 9 | ```bash 10 | pip install django-lasuite 11 | ``` 12 | 13 | ## Configuration 14 | 15 | ### Settings 16 | 17 | Add the following to your Django settings: 18 | 19 | ```python 20 | # Add the authentication backend 21 | AUTHENTICATION_BACKENDS = [ 22 | 'lasuite.oidc_login.backends.OIDCAuthenticationBackend', 23 | ] 24 | 25 | # Authentication to support OIDC silent login flows via the 'silent' query parameter 26 | OIDC_AUTHENTICATE_CLASS = "lasuite.oidc_login.views.OIDCAuthenticationRequestView" 27 | OIDC_CALLBACK_CLASS = "lasuite.oidc_login.views.OIDCAuthenticationCallbackView" 28 | 29 | # Required OIDC settings 30 | OIDC_RP_CLIENT_ID = "your-client-id" 31 | OIDC_RP_CLIENT_SECRET = "your-client-secret" 32 | OIDC_OP_TOKEN_ENDPOINT = "https://your-provider.com/token" 33 | OIDC_OP_USER_ENDPOINT = "https://your-provider.com/userinfo" 34 | OIDC_OP_LOGOUT_ENDPOINT = "https://your-provider.com/logout" 35 | OIDC_OP_USER_ENDPOINT_FORMAT = "AUTO" # AUTO, JSON, or JWT, defaults to AUTO 36 | 37 | # Optional settings 38 | OIDC_USER_SUB_FIELD = "sub" # Field to store the OIDC subject identifier, defaults to "sub" 39 | OIDC_USERINFO_FULLNAME_FIELDS = ["first_name", "last_name"] # Fields used to compute user's full name, defaults to `[]` 40 | OIDC_USERINFO_ESSENTIAL_CLAIMS = ["sub", "last_name"] # Claims required for user identification, defaults to `[]` 41 | OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION = True # Allow fallback to email for user identification 42 | OIDC_CREATE_USER = True # Automatically create users if they don't exist, defaults to `True` 43 | ``` 44 | 45 | ### URLs 46 | 47 | Include the OIDC URLs in your project's `urls.py`: 48 | 49 | ```python 50 | from django.urls import include, path 51 | 52 | urlpatterns = [ 53 | # Your other URLs 54 | path('', include('lasuite.oidc_login.urls')), 55 | ] 56 | ``` 57 | 58 | ## User Model Requirements 59 | 60 | Your User model should include the following fields: 61 | 62 | 1. `sub` - To store the OIDC subject identifier, you may replace this with 63 | another field if needed but needs to set the `OIDC_USER_SUB_FIELD` setting 64 | 2. `email` - For user identification (especially if fallback to email is enabled) 65 | 3. `name` - To store user's full name (computed from fields defined in `OIDC_USERINFO_FULLNAME_FIELDS`) 66 | 67 | ## Authentication Flow 68 | 69 | 1. User is redirected to the OIDC provider login page 70 | 2. After successful authentication, the provider redirects back to your app 71 | 3. The backend verifies the authentication and: 72 | - Retrieves an existing user based on the `sub` field or falls back to email 73 | - Creates a new user if no match is found (when `OIDC_CREATE_USER=True`) 74 | - Updates user information if needed 75 | 4. User is now authenticated in your application 76 | 77 | ## Logout Functionality 78 | 79 | The package includes custom logout views that will properly sign the user out from both your application and the OIDC provider. 80 | 81 | ## Customization 82 | 83 | To customize the behavior of the OIDC authentication backend, you can create your own subclass: 84 | 85 | ```python 86 | from lasuite.oidc_login.backends import OIDCAuthenticationBackend 87 | 88 | 89 | class CustomOIDCAuthenticationBackend(OIDCAuthenticationBackend): 90 | def get_extra_claims(self, user_info): 91 | # Add custom claims processing 92 | claims = super().get_extra_claims(user_info) 93 | claims['custom_field'] = user_info.get('custom_field') 94 | return claims 95 | 96 | def post_get_or_create_user(self, user, claims, is_new_user): 97 | """ 98 | Post-processing after user creation or retrieval. 99 | 100 | Args: 101 | user (User): The user instance. 102 | claims (dict): The claims dictionary. 103 | is_new_user (bool): Indicates if the user was newly created. 104 | 105 | Returns: 106 | - None 107 | 108 | """ 109 | # Add custom post-processing 110 | ``` 111 | 112 | Then update your `AUTHENTICATION_BACKENDS` setting to use your custom class. 113 | -------------------------------------------------------------------------------- /tests/oidc_resource_server/test_utils.py: -------------------------------------------------------------------------------- 1 | """Test for the Resource Server (RS) utils functions.""" 2 | 3 | import pytest 4 | from django.core.exceptions import ImproperlyConfigured 5 | from joserfc.jwk import ECKey, RSAKey 6 | 7 | from lasuite.oidc_resource_server.utils import import_private_key_from_settings 8 | 9 | RSA_PRIVATE_KEY_STR_MOCKED = """-----BEGIN PRIVATE KEY----- 10 | MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQC3boG1kwEGUYL+ 11 | U58RPrVToIsF9jHB64S6WJIIInPmAclBciXFb6BWG11mbRIgo8ha3WVnC/tGHbXb 12 | ndiKdrH2vKHOsDhV9AmgHgNgWaUK9L0uuKEb/xMLePYWsYlgzcQJx8RZY7RQyWqE 13 | 20WfzFxeuCE7QMb6VXSOgwQMnJsKocguIh3VCI9RIBq3B1kdgW35AD63YKOygmGx 14 | qjcWwbjhKLvkF7LpBdlyAEzOKqg4T5uCcHMfksMW2+foTJx70RrZM/KHU+Zysuw7 15 | uhhVsgPBG+CsqBSjHQhs7jzymqxtQAfe1FkrCRxOq5Pv2Efr7kgtVSkJJiX3KutM 16 | vnWuEypxAgMBAAECggEAGqKS9pbrN+vnmb7yMsqYgVVnQn0aggZNHlLkl4ZLLnuV 17 | aemlhur7zO0JzajqUC+AFQOfaQxiFu8S/FoJ+qccFdATrcPEVmTKbgPVqSyzLKlX 18 | fByGll5eOVT95NMwN8yBGgt2HSW/ZditXS/KxxahVgamGqjAC9MTSutGz/8Ae1U+ 19 | DNDBJCc6RAqu3T02tV9A2pSpVC1rSktDMpLUTscnsfxpaEQATd9DJUcHEvIwoX8q 20 | GJpycPEhNhdPXqpln5SoMHcf/zS5ssF/Mce0lJJXYyE0LnEk9X12jMWyBqmLqXUY 21 | cKLyynaFbis0DpQppwKx2y8GpL76k+Ci4dOHIvFknQKBgQDj/2WRMcWOvfBrggzj 22 | FHpcme2gSo5A5c0CVyI+Xkf1Zab6UR6T7GiImEoj9tq0+o2WEix9rwoypgMBq8rz 23 | /rrJAPSZjgv6z71k4EnO2FIB5R03vQmoBRCN8VlgvLM0xv52zyjV4Wx66Q4MDjyH 24 | EgkpHyB0FzRZh0UzhnE/pYSetQKBgQDN9eLB1nA4CBSr1vMGNfQyfBQl3vpO9EP4 25 | VSS3KnUqCIjJeLu682Ylu7SFxcJAfzUpy5S43hEvcuJsagsVKfmCAGcYZs9/xq3I 26 | vzYyhaEOS5ezNxLSh4+yCNBPlmrmDyoazag0t8H8YQFBN6BVcxbATHqdWGUhIhYN 27 | eEpEMOh2TQKBgGBr7kRNTENlyHtu8IxIaMcowfn8DdUcWmsW9oBx1vTNHKTYEZp1 28 | bG/4F8LF7xCCtcY1wWMV17Y7xyG5yYcOv2eqY8dc72wO1wYGZLB5g5URlB2ycJcC 29 | LVIaM7ZZl2BGl+8fBSIOx5XjYfFvQ+HLmtwtMchm19jVAEseHF7SXRfRAoGAK15j 30 | aT2mU6Yf9C9G7T/fM+I8u9zACHAW/+ut14PxN/CkHQh3P16RW9CyqpiB1uLyZuKf 31 | Zm4cYElotDuAKey0xVMgYlsDxnwni+X3m5vX1hLE1s/5/qrc7zg75QZfbCI1U3+K 32 | s88d4e7rPLhh4pxhZgy0pP1ADkIHMr7ppIJH8OECgYEApNfbgsJVPAMzucUhJoJZ 33 | OmZHbyCtJvs4b+zxnmhmSbopifNCgS4zjXH9qC7tsUph1WE6L2KXvtApHGD5H4GQ 34 | IH5em4M/pHIcsqCi1qggBMbdvzHBUtC3R4sK0CpEFHlN+Y59aGazidcN2FPupNJv 35 | MbyqKyC6DAzv4jEEhHaN7oY= 36 | -----END PRIVATE KEY----- 37 | """ 38 | 39 | EC_PRIVATE_KEY_STR_MOCKED = """-----BEGIN PRIVATE KEY----- 40 | MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQg2b4txis/lwlN0rel 41 | qIfoiI3Cvh/CayKIRdkDss/JH6ChRANCAASU9fBsor68yGJI99HtEAbbP1spm6ze 42 | F8kB8c5c8uNrwpdMpd8whM/4nbA9Kh5Jms8fMDQq61Ox8xaVyzy9VW44 43 | -----END PRIVATE KEY----- 44 | """ 45 | 46 | 47 | @pytest.mark.parametrize("mocked_private_key", [None, ""]) 48 | def test_import_private_key_from_settings_missing_or_empty_key(settings, mocked_private_key): 49 | """Should raise an exception if the settings 'OIDC_RS_PRIVATE_KEY_STR' is missing or empty.""" 50 | settings.OIDC_RS_PRIVATE_KEY_STR = RSA_PRIVATE_KEY_STR_MOCKED 51 | settings.OIDC_RS_PRIVATE_KEY_STR = mocked_private_key 52 | 53 | with pytest.raises( 54 | ImproperlyConfigured, 55 | match="OIDC_RS_PRIVATE_KEY_STR setting is missing or empty.", 56 | ): 57 | import_private_key_from_settings() 58 | 59 | 60 | @pytest.mark.parametrize("mocked_private_key", ["123", "foo", "invalid_key"]) 61 | def test_import_private_key_from_settings_incorrect_key(settings, mocked_private_key): 62 | """Should raise an exception if the setting 'OIDC_RS_PRIVATE_KEY_STR' has an incorrect value.""" 63 | settings.OIDC_RS_PRIVATE_KEY_STR = RSA_PRIVATE_KEY_STR_MOCKED 64 | settings.OIDC_RS_ENCRYPTION_KEY_TYPE = "RSA" 65 | settings.OIDC_RS_ENCRYPTION_ALGO = "RS256" 66 | settings.OIDC_RS_PRIVATE_KEY_STR = mocked_private_key 67 | 68 | with pytest.raises(ImproperlyConfigured, match="OIDC_RS_PRIVATE_KEY_STR setting is wrong."): 69 | import_private_key_from_settings() 70 | 71 | 72 | def test_import_private_key_from_settings_success_rsa_key(settings): 73 | """Should import private key string as an RSA key.""" 74 | settings.OIDC_RS_PRIVATE_KEY_STR = RSA_PRIVATE_KEY_STR_MOCKED 75 | settings.OIDC_RS_ENCRYPTION_KEY_TYPE = "RSA" 76 | settings.OIDC_RS_ENCRYPTION_ALGO = "RS256" 77 | private_key = import_private_key_from_settings() 78 | assert isinstance(private_key, RSAKey) 79 | 80 | 81 | def test_import_private_key_from_settings_success_ec_key(settings): 82 | """Should import private key string as an EC key.""" 83 | settings.OIDC_RS_PRIVATE_KEY_STR = EC_PRIVATE_KEY_STR_MOCKED 84 | settings.OIDC_RS_ENCRYPTION_KEY_TYPE = "EC" 85 | settings.OIDC_RS_ENCRYPTION_ALGO = "ES256" 86 | 87 | private_key = import_private_key_from_settings() 88 | assert isinstance(private_key, ECKey) 89 | -------------------------------------------------------------------------------- /src/lasuite/oidc_resource_server/clients.py: -------------------------------------------------------------------------------- 1 | """Resource Server Clients classes.""" 2 | 3 | import requests 4 | from django.conf import settings 5 | from django.core.exceptions import ImproperlyConfigured 6 | from joserfc.jwk import KeySet 7 | 8 | 9 | class AuthorizationServerClient: 10 | """ 11 | Client for interacting with an OAuth 2.0 authorization server. 12 | 13 | An authorization server issues access tokens to client applications after authenticating 14 | and obtaining authorization from the resource owner. It also provides endpoints for token 15 | introspection and JSON Web Key Sets (JWKS) to validate and decode tokens. 16 | 17 | This client facilitates communication with the authorization server, including: 18 | - Fetching token introspection responses. 19 | - Fetching JSON Web Key Sets (JWKS) for token validation. 20 | - Setting appropriate headers for secure communication as recommended by RFC drafts. 21 | """ 22 | 23 | _header_accept = "application/json" 24 | 25 | def __init__(self): 26 | """Require at a minimum url, url_jwks and url_introspection.""" 27 | self.url = settings.OIDC_OP_URL 28 | self._verify_ssl = settings.OIDC_VERIFY_SSL 29 | self._timeout = settings.OIDC_TIMEOUT 30 | self._proxy = settings.OIDC_PROXY 31 | self._url_introspection = settings.OIDC_OP_INTROSPECTION_ENDPOINT 32 | 33 | if not self.url or not self._url_introspection: 34 | raise ImproperlyConfigured(f"Could not instantiate {self.__class__.__name__}, some parameters are missing.") 35 | 36 | @property 37 | def _introspection_headers(self): 38 | """ 39 | Get HTTP header for the introspection request. 40 | 41 | Notify the authorization server that we expect a signed and encrypted response 42 | by setting the appropriate 'Accept' header. 43 | 44 | This follows the recommendation from the draft RFC: 45 | https://datatracker.ietf.org/doc/html/draft-ietf-oauth-jwt-introspection-response-12. 46 | """ 47 | return { 48 | "Content-Type": "application/x-www-form-urlencoded", 49 | "Accept": self._header_accept, 50 | } 51 | 52 | def get_introspection(self, client_id, client_secret, token): 53 | """Retrieve introspection response about a token.""" 54 | response = requests.post( 55 | self._url_introspection, 56 | data={ 57 | "client_id": client_id, 58 | "client_secret": client_secret, 59 | "token": token, 60 | }, 61 | headers=self._introspection_headers, 62 | verify=self._verify_ssl, 63 | timeout=self._timeout, 64 | proxies=self._proxy, 65 | ) 66 | response.raise_for_status() 67 | return response.text 68 | 69 | def get_jwks(self): 70 | """Retrieve Authorization Server JWKS.""" 71 | raise RuntimeError("get_jwks must not be used in JSON introspection mode.") 72 | 73 | def import_public_keys(self): 74 | """Retrieve and import Authorization Server JWKS.""" 75 | raise RuntimeError("import_public_keys must not be used in JSON introspection mode.") 76 | 77 | 78 | class JWTAuthorizationServerClient(AuthorizationServerClient): 79 | """ 80 | Client for interacting with an OAuth 2.0 authorization server. 81 | 82 | This client is specifically designed for authorization servers that use JWTs (JSON Web Tokens) 83 | """ 84 | 85 | _header_accept = "application/token-introspection+jwt" 86 | 87 | def __init__(self): 88 | """Require at a minimum url, url_jwks and url_introspection.""" 89 | super().__init__() 90 | self._url_jwks = settings.OIDC_OP_JWKS_ENDPOINT 91 | 92 | if not self._url_jwks: 93 | raise ImproperlyConfigured(f"Could not instantiate {self.__class__.__name__}, some parameters are missing.") 94 | 95 | def get_jwks(self): 96 | """Retrieve Authorization Server JWKS.""" 97 | response = requests.get( 98 | self._url_jwks, 99 | verify=self._verify_ssl, 100 | timeout=self._timeout, 101 | proxies=self._proxy, 102 | ) 103 | response.raise_for_status() 104 | return response.json() 105 | 106 | def import_public_keys(self): 107 | """Retrieve and import Authorization Server JWKS.""" 108 | jwks = self.get_jwks() 109 | return KeySet.import_key_set(jwks) 110 | -------------------------------------------------------------------------------- /documentation/how-to-use-malware-detection-backend.md: -------------------------------------------------------------------------------- 1 | # Using malware detection backend 2 | 3 | If you are developing a service where users can upload files, you will probably want to check if these files are safe or not. 4 | 5 | The malware detection app is here to give you several backend implementations and others can be added to match your requirements. 6 | 7 | ## Global idea 8 | 9 | A backend should inherit from `lasuite.malware_detection.backends.base.BaseBackend` class and implement the abstract methods declared in it. 10 | The backend service then is responsible to analyse the file and give an answer by calling a defined callback. 11 | 12 | ### Abstract methods 13 | 14 | - `def analyse_file(self, file_path: str, **kwargs) -> None:`: it is the entrypoint to analyse a file. This method returns nothing and can analyse the file by using a celery task. The parameters to use: 15 | - `file_path`: the path to access the file. This path must be accessible with the django default storage. 16 | - `kwargs`: arbitrary kwargs usable by the user using the library. These kwargs will be used by the callback. You can for example pass a resource id or whatever useful for you. 17 | 18 | ### How to use a backend 19 | 20 | We provide a handler responsible to instantiate and configure a backend. You have to declare in your settings the backend to use with their needed parameters. 21 | 22 | The settings to use is `settings.MALWARE_DETECTION`. It is a `dict` containing two keys: `BACKEND` and `PARAMETERS`. The `BACKEND` is the full path to the backend class and the `PARAMETERS` is a dict containing all the parameters needed to instantiate the backend class. 23 | 24 | Example: 25 | 26 | ```python 27 | settings.MALWARE_DETECTION = { 28 | "BACKEND": "lasuite.malware_detection.backends.dummy.DummyBackend", 29 | "PARAMETERS": { 30 | "callback_path": "path.to.your.callback", 31 | }, 32 | } 33 | ``` 34 | 35 | Every backend has at least the `callback_path` parameter. The callback is explained just before. 36 | 37 | Then to use the backend in your code, you have to import the `lasuite.malware_detection.malware_detection` and call the `analyse_file` method. 38 | 39 | Example: 40 | 41 | ```python 42 | from lasuite.malware_detection import malware_detection 43 | 44 | malware_detection.analyse_file("path/to/your/file.txt") 45 | ``` 46 | 47 | 48 | ### Callback 49 | 50 | In order to know the detection status, as it can be asynchronous, you have to create your own callback. This callback is a function that will be called once the analysis is done. 51 | 52 | This function receives these parameters: 53 | - `file_path` the `file_path` used in the `analyse_file` method 54 | - `status` the analysis status (see `lasuite.malware_detection.enums.ReportStatus`). It can be `safe`, `unsafe` and `unknown`. In case of `unknown` you are responsible to determine what you should do 55 | - `error_info` a dict containing `error` and `error_code` properties. 56 | - `kwargs` the kwargs you pass to the `analyse_file` method 57 | 58 | ## Existing implementations 59 | 60 | ### Dummy 61 | 62 | path: `lasuite.malware_detection.backends.dummy.DummyBackend` 63 | parameters: 64 | - `callback_path`: the full path to the callback. 65 | 66 | The `dummy` backend does nothing. It calls the `callback` with always the `safe` status. It can be useful in a test context or to deactivate analysis. 67 | 68 | Example: 69 | 70 | ```python 71 | settings.MALWARE_DETECTION = { 72 | "BACKEND": "lasuite.malware_detection.backends.dummy.DummyBackend", 73 | "PARAMETERS": { 74 | "callback_path": "path.to.your.callback", 75 | }, 76 | } 77 | ``` 78 | 79 | ### JCOP 80 | 81 | `JCOP` means for [Je Clique Ou Pas](https://jecliqueoupas.cyber.gouv.fr/accueil). To use it you need the url and the api token provided by them. 82 | 83 | path: `lasuite.malware_detection.backends.jcop.JCOPBackend` 84 | parameters: 85 | - `api_key`: The API key provided by JCOP 86 | - `base_url`: The base URL provided by JCOP (including the `/api/v1` base path) 87 | - `callback_path`: the full path to the callback. 88 | - `result_timeout`: The timeout for the result request. Default: 30 (optional) 89 | - `submit_timeout`: The timeout for the submit request. Default: 600 (optional) 90 | 91 | Example: 92 | 93 | ```python 94 | settings.MALWARE_DETECTION = { 95 | "BACKEND": "lasuite.malware_detection.backends.jcop.JCOPBackend", 96 | "PARAMETERS": { 97 | "callback_path": "path.to.your.callback", 98 | "api_key": "xxx", 99 | "base_url": "https://malware_detection.tld/api/v1" 100 | }, 101 | } 102 | ``` 103 | -------------------------------------------------------------------------------- /documentation/how-to-use-oidc-call-to-resource-server.md: -------------------------------------------------------------------------------- 1 | # Using the OIDC Authentication Backend to request a resource server 2 | 3 | Once your project is configured with the OIDC authentication backend, you can use it to request resources from a resource server. This guide will help you set up and use the `ResourceServerBackend` for token introspection and secure API access. 4 | 5 | ## Configuration 6 | 7 | You need to follow the steps from [how-to-use-oidc-backend.md](how-to-use-oidc-backend.md) 8 | 9 | ## Additional Settings for Resource Server Communication 10 | 11 | To enable your application to communicate with protected resource servers, you'll need to configure token storage in your Django settings: 12 | 13 | ```python 14 | # Store OIDC tokens in the session 15 | OIDC_STORE_ACCESS_TOKEN = True # Store the access token in the session 16 | OIDC_STORE_REFRESH_TOKEN = True # Store the encrypted refresh token in the session 17 | 18 | # Required for refresh token encryption 19 | OIDC_STORE_REFRESH_TOKEN_KEY = "your-32-byte-encryption-key==" # Must be a valid Fernet key (32 url-safe base64-encoded bytes) 20 | ``` 21 | 22 | ### Purpose of Each Setting 23 | 24 | 1. **`OIDC_STORE_ACCESS_TOKEN`**: When set to `True`, the access token received from the OIDC provider will be stored in the user's session. This token is required for making authenticated requests to protected resource servers. 25 | 26 | 2. **`OIDC_STORE_REFRESH_TOKEN`**: When set to `True`, enables storing the refresh token in the user's session. The refresh token allows your application to request a new access token when the current one expires without requiring user re-authentication. 27 | 28 | 3. **`OIDC_STORE_REFRESH_TOKEN_KEY`**: This is a cryptographic key used to encrypt the refresh token before storing it in the session. This provides an additional layer of security since refresh tokens are sensitive credentials that can be used to obtain new access tokens. 29 | 30 | ## Generating a Secure Refresh Token Key 31 | 32 | You can generate a secure Fernet key using Python: 33 | 34 | ```python 35 | from cryptography.fernet import Fernet 36 | key = Fernet.generate_key() 37 | print(key.decode()) # Add this value to your settings 38 | ``` 39 | 40 | ## Using the Stored Tokens 41 | 42 | Once you have configured these settings, your application can use the stored tokens to make authenticated requests to resource servers: 43 | 44 | ```python 45 | import requests 46 | from django.http import JsonResponse 47 | 48 | def call_resource_server(request): 49 | # Get the access token from the session 50 | access_token = request.session.get('oidc_access_token') 51 | 52 | if not access_token: 53 | return JsonResponse({'error': 'Not authenticated'}, status=401) 54 | 55 | # Make an authenticated request to the resource server 56 | response = requests.get( 57 | 'https://resource-server.example.com/api/resource', 58 | headers={'Authorization': f'Bearer {access_token}'}, 59 | ) 60 | 61 | return JsonResponse(response.json()) 62 | ``` 63 | 64 | ## Token Refresh management 65 | 66 | ### View Based Token Refresh (via decorator) 67 | 68 | Request the access token refresh only on specific views using the `refresh_oidc_access_token` decorator: 69 | 70 | ```python 71 | from lasuite.oidc_login.decorators import refresh_oidc_access_token 72 | 73 | class SomeViewSet(GenericViewSet): 74 | 75 | @method_decorator(refresh_oidc_access_token) 76 | def some_action(self, request): 77 | # Your action logic here 78 | 79 | # The call to the resource server 80 | access_token = request.session.get('oidc_access_token') 81 | requests.get( 82 | 'https://resource-server.example.com/api/resource', 83 | headers={'Authorization': f'Bearer {access_token}'}, 84 | ) 85 | ``` 86 | 87 | This will trigger the token refresh process only when the `some_action` method is called. 88 | If the access token is expired, it will attempt to refresh it using the stored refresh token. 89 | 90 | ### Automatic Token Refresh (via middleware) 91 | 92 | You can also use the `RefreshOIDCAccessToken` middleware to automatically refresh expired tokens: 93 | 94 | ```python 95 | # Add to your MIDDLEWARE setting 96 | MIDDLEWARE = [ 97 | # Other middleware... 98 | 'lasuite.oidc_login.middleware.RefreshOIDCAccessToken', 99 | ] 100 | ``` 101 | 102 | This middleware will: 103 | 1. Check if the current access token is expired 104 | 2. Use the stored refresh token to obtain a new access token 105 | 3. Update the session with the new token 106 | 4. Continue processing the request with the fresh token 107 | 108 | If token refresh fails, the middleware will return a 401 response with a `refresh_url` header to redirect the user to re-authenticate. 109 | 110 | -------------------------------------------------------------------------------- /tests/marketing/backends/test_brevo_backend.py: -------------------------------------------------------------------------------- 1 | """Test the Brevo marketing backend.""" 2 | 3 | import pytest 4 | import responses 5 | from responses import matchers 6 | 7 | from lasuite.marketing.backends import ContactData 8 | from lasuite.marketing.backends.brevo import BrevoBackend 9 | from lasuite.marketing.exceptions import ContactCreationError 10 | 11 | 12 | @responses.activate 13 | def test_create_contact_success_without_existing_brevo_contact(): 14 | """Test successful contact creation.""" 15 | responses.add( 16 | responses.GET, 17 | "https://api.brevo.com/v3/contacts/test%40example.com?identifierType=email_id", 18 | status=404, 19 | ) 20 | 21 | responses.add( 22 | responses.POST, 23 | "https://api.brevo.com/v3/contacts", 24 | headers={"api-key": "test-api-key"}, 25 | json={ 26 | "id": "test-id", 27 | }, 28 | status=201, 29 | match=[ 30 | matchers.json_params_matcher( 31 | { 32 | "email": "test@example.com", 33 | "updateEnabled": True, 34 | "listIds": [1, 2, 3], 35 | "attributes": {"source": "test", "first_name": "Test"}, 36 | } 37 | ) 38 | ], 39 | ) 40 | 41 | valid_contact_data = ContactData( 42 | email="test@example.com", 43 | attributes={"first_name": "Test"}, 44 | list_ids=[1, 2], 45 | update_enabled=True, 46 | ) 47 | 48 | brevo_service = BrevoBackend( 49 | api_key="test-api-key", 50 | api_contact_list_ids=[1, 2, 3], 51 | api_contact_attributes={"source": "test"}, 52 | ) 53 | 54 | response = brevo_service.create_or_update_contact(valid_contact_data) 55 | 56 | assert response == {"id": "test-id"} 57 | 58 | 59 | @responses.activate 60 | def test_create_contact_success_with_existing_brevo_contact(): 61 | """Test successful contact creation.""" 62 | responses.add( 63 | responses.GET, 64 | "https://api.brevo.com/v3/contacts/test%40example.com?identifierType=email_id", 65 | status=200, 66 | json={"id": "test-id", "listIds": [4, 5]}, 67 | ) 68 | 69 | responses.add( 70 | responses.POST, 71 | "https://api.brevo.com/v3/contacts", 72 | headers={"api-key": "test-api-key"}, 73 | json={"id": "test-id"}, 74 | status=201, 75 | match=[ 76 | matchers.json_params_matcher( 77 | { 78 | "email": "test@example.com", 79 | "updateEnabled": True, 80 | "listIds": [1, 2, 3, 4, 5], 81 | "attributes": {"source": "test", "first_name": "Test"}, 82 | } 83 | ) 84 | ], 85 | ) 86 | 87 | valid_contact_data = ContactData( 88 | email="test@example.com", 89 | attributes={"first_name": "Test"}, 90 | list_ids=[1, 2], 91 | update_enabled=True, 92 | ) 93 | 94 | brevo_service = BrevoBackend( 95 | api_key="test-api-key", 96 | api_contact_list_ids=[1, 2, 3], 97 | api_contact_attributes={"source": "test"}, 98 | ) 99 | 100 | response = brevo_service.create_or_update_contact(valid_contact_data) 101 | 102 | assert response == {"id": "test-id"} 103 | 104 | 105 | @responses.activate 106 | def test_create_contact_api_error(): 107 | """Test contact creation API error handling.""" 108 | responses.add( 109 | responses.GET, 110 | "https://api.brevo.com/v3/contacts/test%40example.com?identifierType=email_id", 111 | status=404, 112 | ) 113 | 114 | responses.add( 115 | responses.POST, 116 | "https://api.brevo.com/v3/contacts", 117 | headers={"api-key": "test-api-key"}, 118 | json={"id": "test-id"}, 119 | status=400, 120 | match=[ 121 | matchers.json_params_matcher( 122 | { 123 | "email": "test@example.com", 124 | "updateEnabled": True, 125 | "listIds": [1, 2, 3], 126 | "attributes": {"source": "test", "first_name": "Test"}, 127 | } 128 | ) 129 | ], 130 | ) 131 | 132 | valid_contact_data = ContactData( 133 | email="test@example.com", 134 | attributes={"first_name": "Test"}, 135 | list_ids=[1, 2], 136 | update_enabled=True, 137 | ) 138 | 139 | brevo_service = BrevoBackend( 140 | api_key="test-api-key", 141 | api_contact_list_ids=[1, 2, 3], 142 | api_contact_attributes={"source": "test"}, 143 | ) 144 | 145 | with pytest.raises(ContactCreationError, match="Failed to create contact in Brevo"): 146 | brevo_service.create_or_update_contact(valid_contact_data) 147 | -------------------------------------------------------------------------------- /tests/drf/models/test_choices.py: -------------------------------------------------------------------------------- 1 | """Test the choices for application managing accesses.""" 2 | 3 | import pytest 4 | 5 | from lasuite.drf.models import choices 6 | 7 | 8 | class DummyChoices(choices.PriorityTextChoices): 9 | """Dummy choices for testing.""" 10 | 11 | ONE = "one", "One" 12 | TWO = "two", "Two" 13 | THREE = "three", "Three" 14 | 15 | 16 | def test_priority_text_choices_get_priotity(): 17 | """Test the get_priority method of the PriorityTextChoices class.""" 18 | assert DummyChoices.get_priority("one") == 1 19 | assert DummyChoices.get_priority("two") == 2 20 | assert DummyChoices.get_priority("three") == 3 21 | assert DummyChoices.get_priority("four") == 0 22 | 23 | 24 | def test_priority_text_choices_max(): 25 | """Test the max method of the PriorityTextChoices class.""" 26 | assert DummyChoices.max("one", "two", "three") == "three" 27 | assert DummyChoices.max("one", "three") == "three" 28 | assert DummyChoices.max("one", "two", "four") == "two" 29 | assert DummyChoices.max("one", "two", "three", "four") == "three" 30 | 31 | 32 | @pytest.mark.parametrize( 33 | ("reach", "role", "select_options"), 34 | [ 35 | ( 36 | "public", 37 | "reader", 38 | { 39 | "public": ["reader", "editor"], 40 | }, 41 | ), 42 | ("public", "editor", {"public": ["editor"]}), 43 | ( 44 | "authenticated", 45 | "reader", 46 | { 47 | "authenticated": ["reader", "editor"], 48 | "public": ["reader", "editor"], 49 | }, 50 | ), 51 | ( 52 | "authenticated", 53 | "editor", 54 | {"authenticated": ["editor"], "public": ["editor"]}, 55 | ), 56 | ( 57 | "restricted", 58 | "reader", 59 | { 60 | "restricted": None, 61 | "authenticated": ["reader", "editor"], 62 | "public": ["reader", "editor"], 63 | }, 64 | ), 65 | ( 66 | "restricted", 67 | "editor", 68 | { 69 | "restricted": None, 70 | "authenticated": ["editor"], 71 | "public": ["editor"], 72 | }, 73 | ), 74 | # Edge cases 75 | ( 76 | "public", 77 | None, 78 | { 79 | "public": ["reader", "editor"], 80 | }, 81 | ), 82 | ( 83 | None, 84 | "reader", 85 | { 86 | "public": ["reader", "editor"], 87 | "authenticated": ["reader", "editor"], 88 | "restricted": None, 89 | }, 90 | ), 91 | ( 92 | None, 93 | None, 94 | { 95 | "public": ["reader", "editor"], 96 | "authenticated": ["reader", "editor"], 97 | "restricted": None, 98 | }, 99 | ), 100 | ], 101 | ) 102 | def test_models_documents_get_select_options(reach, role, select_options): 103 | """Validate that the "get_select_options" method operates as expected.""" 104 | assert choices.LinkReachChoices.get_select_options(reach, role) == select_options 105 | 106 | 107 | @pytest.mark.parametrize( 108 | ("ancestors_links", "expected_result"), 109 | [ 110 | ( 111 | [ 112 | {"link_reach": "restricted", "link_role": "public"}, 113 | {"link_reach": "authenticated", "link_role": "editor"}, 114 | {"link_reach": "authenticated", "link_role": "reader"}, 115 | ], 116 | {"link_reach": "authenticated", "link_role": "editor"}, 117 | ), 118 | ( 119 | [ 120 | {"link_reach": "restricted", "link_role": "public"}, 121 | {"link_reach": "authenticated", "link_role": "editor"}, 122 | {"link_reach": "public", "link_role": "reader"}, 123 | ], 124 | {"link_reach": "public", "link_role": "reader"}, 125 | ), 126 | ( 127 | [ 128 | {"link_reach": "restricted", "link_role": "public"}, 129 | {"link_reach": "authenticated", "link_role": "editor"}, 130 | {"link_reach": "public", "link_role": "reader"}, 131 | {"link_reach": "public", "link_role": "editor"}, 132 | ], 133 | {"link_reach": "public", "link_role": "editor"}, 134 | ), 135 | ], 136 | ) 137 | def test_models_documents_get_equivalent_link_definition(ancestors_links, expected_result): 138 | """Test the "get_equivalent_link_definition" method.""" 139 | assert choices.get_equivalent_link_definition(ancestors_links) == expected_result 140 | -------------------------------------------------------------------------------- /src/lasuite/oidc_resource_server/authentication.py: -------------------------------------------------------------------------------- 1 | """Resource Server Authentication.""" 2 | 3 | import base64 4 | import binascii 5 | import contextlib 6 | import logging 7 | from functools import cache 8 | 9 | from django.conf import settings 10 | from django.core.exceptions import ImproperlyConfigured, SuspiciousOperation 11 | from django.utils.module_loading import import_string 12 | from mozilla_django_oidc.contrib.drf import OIDCAuthentication 13 | from mozilla_django_oidc.utils import parse_www_authenticate_header 14 | from requests.exceptions import HTTPError 15 | from rest_framework import exceptions 16 | from rest_framework.status import HTTP_401_UNAUTHORIZED 17 | 18 | from .backend import ResourceServerBackend, ResourceServerImproperlyConfiguredBackend 19 | 20 | logger = logging.getLogger(__name__) 21 | 22 | 23 | @cache 24 | def get_resource_server_backend() -> type[ResourceServerBackend]: 25 | """Return the resource server backend class based on the settings.""" 26 | return import_string(settings.OIDC_RS_BACKEND_CLASS) 27 | 28 | 29 | class ResourceServerAuthentication(OIDCAuthentication): 30 | """ 31 | Authenticate clients using the token received from the authorization server. 32 | 33 | We still inherit from OIDCAuthentication for the basic token extraction, 34 | but the authenticate method is fully overridden. 35 | """ 36 | 37 | def __init__(self): 38 | """Require authentication to be configured in order to instantiate.""" 39 | try: 40 | super().__init__(backend=get_resource_server_backend()()) 41 | except ImproperlyConfigured as err: 42 | message = "Resource Server authentication is disabled" 43 | logger.debug("%s. Exception: %s", message, err) 44 | self.backend = ResourceServerImproperlyConfiguredBackend() 45 | 46 | def get_access_token(self, request): 47 | """ 48 | Retrieve and decode the access token from the request. 49 | 50 | This method overrides the 'get_access_token' method from the parent class, 51 | to support service providers that would base64 encode the bearer token. 52 | """ 53 | access_token = super().get_access_token(request) 54 | 55 | with contextlib.suppress(binascii.Error, TypeError, UnicodeDecodeError): 56 | access_token = base64.b64decode(access_token, validate=True).decode("utf-8") 57 | 58 | return access_token 59 | 60 | def authenticate(self, request): 61 | """ 62 | Authenticate the request and return a tuple of (user, token) or None. 63 | 64 | We fully override the 'authenticate' method from the parent class: 65 | - to be able to introspect the access token earlier (doing it in the 66 | get_user method would be too late); 67 | - to store the introspected token audience inside the request to allow the 68 | views to use it later (for permission restriction). 69 | 70 | The implementation is still highly inspired from the parent class. 71 | """ 72 | access_token = self.get_access_token(request) 73 | 74 | if not access_token: 75 | # Defer to next authentication backend 76 | return None 77 | 78 | # Custom addition: introspect the access token to ensure it's valid 79 | # and retrieve user info. 80 | try: 81 | user_info = self.backend.get_user_info_with_introspection(access_token) 82 | except HTTPError as exc: 83 | resp = exc.response 84 | 85 | # If the oidc provider returns 401, it means the token is invalid or that 86 | # the introspection is not allowed. 87 | # In that case, we want to return the upstream error message (which 88 | # we can get from the www-authentication header) in the response. 89 | if resp.status_code == HTTP_401_UNAUTHORIZED and "www-authenticate" in resp.headers: 90 | data = parse_www_authenticate_header(resp.headers["www-authenticate"]) 91 | raise exceptions.AuthenticationFailed( 92 | data.get("error_description", "no error description in www-authenticate") 93 | ) from exc 94 | 95 | # for all other http errors, just re-raise the exception. 96 | raise 97 | 98 | try: 99 | user = self.backend.get_or_create_user(access_token, None, user_info) 100 | 101 | except SuspiciousOperation as exc: 102 | logger.info("Login failed: %s", exc) 103 | raise exceptions.AuthenticationFailed("Login failed") from exc 104 | 105 | if not user: 106 | msg = "Login failed: No user found for the given access token." 107 | raise exceptions.AuthenticationFailed(msg) 108 | 109 | # Custom addition: store the token audience in the request 110 | # Note: at this stage, the request is a "drf_request" object 111 | request.resource_server_token_audience = self.backend.token_origin_audience 112 | 113 | return user, user_info 114 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0), 6 | and this project adheres to 7 | [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 8 | 9 | ## [Unreleased] 10 | 11 | ## [0.0.22] - 2025-12-04 12 | 13 | ### Added 14 | 15 | - ✨(marketing) create marketing module 16 | 17 | ## [0.0.21] - 2025-12-04 18 | 19 | ### Added 20 | 21 | - ✨(malware) save file hash in detection record and callback 22 | 23 | ## [0.0.20] - 2025-12-02 24 | 25 | ### Added 26 | 27 | - ✨(backend) keep traces of failed malware analysis tasks 28 | - ✨(backend) save backend used in a malware analysis task 29 | - ✨(backend) allow a malware detection backend to reschedule a task 30 | - ✨(malware) add management command to reschedule processing 31 | - ✨(malware) add an admin view to ease tracking tasks 32 | 33 | ## [0.0.19] - 2025-11-21 34 | 35 | ### Changed 36 | 37 | - ♻️(resource-server) make token introspection earlier #46 38 | 39 | ## [0.0.18] - 2025-11-06 40 | 41 | ### Changed 42 | 43 | - 🐛(joserfc) refactor JWT handling with joserfc library updates #35 44 | - 👔(oidc) consider urls as refreshable no matter the HTTP method #42 45 | 46 | ## [0.0.17] - 2025-10-27 47 | 48 | ### Added 49 | 50 | - ✨(backend) extract reach and roles choices #33 51 | 52 | ### Fixed 53 | 54 | - 🐛(oidc) do not allow user sub update when set #34 55 | 56 | 57 | ## [0.0.16] - 2025-10-24 58 | 59 | ### Fixed 60 | 61 | - 🐛(oidc) fix `update_user` when `User.sub` is nullable #31 62 | 63 | 64 | ## [0.0.15] - 2025-10-24 65 | 66 | ### Added 67 | 68 | - ✨(oidc) add backend logout endpoint #28 69 | 70 | ### Fixed 71 | 72 | - 🐛(oidc) validate state param during silent login failure for CSRF protection 73 | - 🐛(oidc) fix session persistence with Redis backend for OIDC flows 74 | 75 | ## [0.0.14] - 2025-09-05 76 | 77 | ### Added 78 | 79 | - ✨(drf) implement monitored scope throttling class #27 80 | 81 | ## [0.0.13] - 2025-08-28 82 | 83 | ### Fixed 84 | 85 | - 🗃️(malware_detection) use dict callable for MalwareDetection 86 | defaut parameters #26 87 | 88 | ## [0.0.12] - 2025-07-22 89 | 90 | ### Added 91 | 92 | - ✨(malware_detection) limit simultaneous files analysis for jcop #25 93 | 94 | ### Fixed 95 | 96 | - 🐛(tests) fix test_project app to be usable with management command #25 97 | 98 | ## [0.0.11] - 2025-07-09 99 | 100 | ### Fixed 101 | 102 | - 🐛(resource-server) allow `aud` & `iss` JWE headers #24 103 | 104 | ## [0.0.10] - 2025-06-18 105 | 106 | ### Fixed 107 | 108 | - 🐛(oidc-rs) fix non base 64 authentication token #21 109 | - 📝(pyproject) fix the package metadata #23 110 | 111 | ## [0.0.9] - 2025-05-20 112 | 113 | ### Added 114 | 115 | - ✨(configuration) add configuration Value to support file path 116 | in environment #15 117 | 118 | ### Changed 119 | 120 | - ♻️(malware_detection) retry getting analyse result sooner 121 | 122 | ## [0.0.8] - 2025-05-06 123 | 124 | ### Added 125 | 126 | - ✨(malware_detection) add a module malware_detection #11 127 | 128 | ### Fixed 129 | 130 | - 🐛(oidc) fix resource server client when using JSON introspection #16 131 | - 🔊(oidc) improve resource server log for inactive user #17 132 | - 🐛(oidc) use the OIDC_USER_SUB_FIELD when needed #18 133 | - 🩹(oidc) remove deprecated cgi use #19 134 | 135 | ## [0.0.7] - 2025-04-23 136 | 137 | ### Fixed 138 | 139 | - 🐛(oidc) fix user info endpoint format auto #12 140 | 141 | ## [0.0.6] - 2025-04-11 142 | 143 | ### Changed 144 | 145 | - 💥(oidc) normalize setting names #10 146 | 147 | ## [0.0.5] - 2025-04-10 148 | 149 | ### Fixed 150 | 151 | - 🐛(oidc) do not allow empty sub claim #9 152 | 153 | ## [0.0.4] - 2025-04-10 154 | 155 | ### Added 156 | 157 | - ✨(oidc) allow silent login authentication #8 158 | 159 | ## [0.0.3] - 2025-04-09 160 | 161 | ### Added 162 | 163 | - ✨(oidc) allow JSON format in user info endpoint #5 164 | - ✨(oidc) add essential claims check setting #6 165 | 166 | ## [0.0.2] - 2025-04-07 167 | 168 | ### Fixed 169 | 170 | - 🐛(oidc-rs) do not check iss in introspection #4 171 | 172 | ## [0.0.1] - 2025-04-03 173 | 174 | ### Added 175 | 176 | - ✨(tools) extract domain from email address #2 177 | - ✨(oidc) add the authentication backends #2 178 | - ✨(oidc) add refresh token tools #3 179 | 180 | [unreleased]: https://github.com/suitenumerique/django-lasuite/compare/v0.0.22...main 181 | [0.0.22]: https://github.com/suitenumerique/django-lasuite/releases/v0.0.22 182 | [0.0.21]: https://github.com/suitenumerique/django-lasuite/releases/v0.0.21 183 | [0.0.20]: https://github.com/suitenumerique/django-lasuite/releases/v0.0.20 184 | [0.0.19]: https://github.com/suitenumerique/django-lasuite/releases/v0.0.19 185 | [0.0.18]: https://github.com/suitenumerique/django-lasuite/releases/v0.0.18 186 | [0.0.17]: https://github.com/suitenumerique/django-lasuite/releases/v0.0.17 187 | [0.0.16]: https://github.com/suitenumerique/django-lasuite/releases/v0.0.16 188 | [0.0.15]: https://github.com/suitenumerique/django-lasuite/releases/v0.0.15 189 | [0.0.14]: https://github.com/suitenumerique/django-lasuite/releases/v0.0.13 190 | [0.0.13]: https://github.com/suitenumerique/django-lasuite/releases/v0.0.13 191 | [0.0.12]: https://github.com/suitenumerique/django-lasuite/releases/v0.0.12 192 | [0.0.11]: https://github.com/suitenumerique/django-lasuite/releases/v0.0.11 193 | [0.0.10]: https://github.com/suitenumerique/django-lasuite/releases/v0.0.10 194 | [0.0.9]: https://github.com/suitenumerique/django-lasuite/releases/v0.0.9 195 | [0.0.8]: https://github.com/suitenumerique/django-lasuite/releases/v0.0.8 196 | [0.0.7]: https://github.com/suitenumerique/django-lasuite/releases/v0.0.7 197 | [0.0.6]: https://github.com/suitenumerique/django-lasuite/releases/v0.0.6 198 | [0.0.5]: https://github.com/suitenumerique/django-lasuite/releases/v0.0.5 199 | [0.0.4]: https://github.com/suitenumerique/django-lasuite/releases/v0.0.4 200 | [0.0.3]: https://github.com/suitenumerique/django-lasuite/releases/v0.0.3 201 | [0.0.2]: https://github.com/suitenumerique/django-lasuite/releases/v0.0.2 202 | [0.0.1]: https://github.com/suitenumerique/django-lasuite/releases/v0.0.1 203 | -------------------------------------------------------------------------------- /tests/malware_detection/tasks/test_jcop_tasks.py: -------------------------------------------------------------------------------- 1 | """Tests for the JCOP tasks.""" 2 | 3 | from unittest import mock 4 | 5 | import pytest 6 | import requests 7 | from celery.exceptions import Retry 8 | 9 | from lasuite.malware_detection.exceptions import MalwareDetectionInvalidAuthenticationError 10 | from lasuite.malware_detection.tasks.jcop import MaxRetriesErrorCodes, analyse_file_async, trigger_new_analysis 11 | 12 | 13 | @pytest.fixture 14 | def mock_backend(): 15 | """Create a mock for the JCOP backend.""" 16 | backend = mock.MagicMock() 17 | handler = mock.MagicMock() 18 | handler.return_value = backend 19 | return backend, handler 20 | 21 | 22 | def test_analyse_file_async_success(mock_backend): 23 | """Test analyse_file_async with successful check_analysis.""" 24 | backend, handler = mock_backend 25 | backend.check_analysis.return_value = False # No retry needed 26 | 27 | with mock.patch("lasuite.malware_detection.tasks.jcop.MalwareDetectionHandler", return_value=handler): 28 | analyse_file_async("file.txt", file_hash="hash123") 29 | 30 | backend.check_analysis.assert_called_once_with("file.txt", file_hash="hash123") 31 | backend.failed_analysis.assert_not_called() 32 | 33 | 34 | def test_analyse_file_async_retry(mock_backend): 35 | """Test analyse_file_async with retry needed.""" 36 | backend, handler = mock_backend 37 | backend.check_analysis.return_value = True # Retry needed 38 | 39 | with ( 40 | mock.patch("lasuite.malware_detection.tasks.jcop.MalwareDetectionHandler", return_value=handler), 41 | pytest.raises(Retry), 42 | ): 43 | analyse_file_async("file.txt") 44 | 45 | backend.check_analysis.assert_called_once_with("file.txt", file_hash=None) 46 | backend.failed_analysis.assert_not_called() 47 | 48 | 49 | def test_analyse_file_async_request_exception_retry(mock_backend): 50 | """Test analyse_file_async with request exception and retry.""" 51 | backend, handler = mock_backend 52 | backend.check_analysis.side_effect = requests.exceptions.RequestException("Connection error") 53 | 54 | with ( 55 | mock.patch("lasuite.malware_detection.tasks.jcop.MalwareDetectionHandler", return_value=handler), 56 | pytest.raises(requests.exceptions.RequestException), 57 | ): 58 | analyse_file_async("file.txt") 59 | 60 | backend.check_analysis.assert_called_once_with("file.txt", file_hash=None) 61 | backend.failed_analysis.assert_not_called() 62 | 63 | 64 | def test_analyse_file_async_request_exception_max_retries(mock_backend): 65 | """Test analyse_file_async with request exception and max retries reached.""" 66 | backend, handler = mock_backend 67 | backend.check_analysis.side_effect = requests.exceptions.RequestException("Connection error") 68 | backend.failed_analysis = mock.MagicMock() 69 | 70 | with mock.patch("lasuite.malware_detection.tasks.jcop.MalwareDetectionHandler", return_value=handler): 71 | analyse_file_async.max_retries = 0 72 | analyse_file_async("file.txt") 73 | 74 | backend.check_analysis.assert_called_once_with("file.txt", file_hash=None) 75 | backend.failed_analysis.assert_called_once_with( 76 | "file.txt", error_code=MaxRetriesErrorCodes.ANALYSE_FILE, error_msg="Max retries fetching results exceeded" 77 | ) 78 | 79 | 80 | def test_analyse_file_async_with_auth_error_no_retry(mock_backend): 81 | """Test analyse_file_async with authentication error (no retry).""" 82 | backend, handler = mock_backend 83 | backend.check_analysis.side_effect = MalwareDetectionInvalidAuthenticationError("Invalid API key") 84 | 85 | with ( 86 | mock.patch("lasuite.malware_detection.tasks.jcop.MalwareDetectionHandler", return_value=handler), 87 | pytest.raises(MalwareDetectionInvalidAuthenticationError), 88 | ): 89 | analyse_file_async("file.txt") 90 | 91 | backend.check_analysis.assert_called_once_with("file.txt", file_hash=None) 92 | backend.failed_analysis.assert_not_called() 93 | 94 | 95 | def test_trigger_new_analysis_success(mock_backend): 96 | """Test trigger_new_analysis with successful execution.""" 97 | backend, handler = mock_backend 98 | 99 | with mock.patch("lasuite.malware_detection.tasks.jcop.MalwareDetectionHandler", return_value=handler): 100 | trigger_new_analysis("file.txt", "file_hash", extra_param="value") 101 | 102 | backend.trigger_new_analysis.assert_called_once_with("file.txt", "file_hash", extra_param="value") 103 | 104 | 105 | def test_trigger_new_analysis_request_exception_retry(mock_backend): 106 | """Test trigger_new_analysis with request exception and retry.""" 107 | backend, handler = mock_backend 108 | backend.trigger_new_analysis.side_effect = requests.exceptions.RequestException("Connection error") 109 | 110 | with ( 111 | mock.patch("lasuite.malware_detection.tasks.jcop.MalwareDetectionHandler", return_value=handler), 112 | pytest.raises(requests.exceptions.RequestException), 113 | ): 114 | trigger_new_analysis("file.txt", "file_hash") 115 | 116 | backend.trigger_new_analysis.assert_called_once_with("file.txt", "file_hash") 117 | backend.failed_analysis.assert_not_called() 118 | 119 | 120 | def test_trigger_new_analysis_timeout_retry(mock_backend): 121 | """Test trigger_new_analysis with timeout error and retry.""" 122 | backend, handler = mock_backend 123 | backend.trigger_new_analysis.side_effect = TimeoutError("Timeout") 124 | 125 | with ( 126 | mock.patch("lasuite.malware_detection.tasks.jcop.MalwareDetectionHandler", return_value=handler), 127 | pytest.raises(TimeoutError), 128 | ): 129 | trigger_new_analysis("file.txt", "file_hash") 130 | 131 | backend.trigger_new_analysis.assert_called_once_with("file.txt", "file_hash") 132 | backend.failed_analysis.assert_not_called() 133 | 134 | 135 | def test_trigger_new_analysis_request_exception_max_retries(mock_backend): 136 | """Test trigger_new_analysis with request exception and max retries reached.""" 137 | backend, handler = mock_backend 138 | backend.trigger_new_analysis.side_effect = requests.exceptions.RequestException("Connection error") 139 | backend.failed_analysis = mock.MagicMock() 140 | with mock.patch("lasuite.malware_detection.tasks.jcop.MalwareDetectionHandler", return_value=handler): 141 | trigger_new_analysis.max_retries = 0 142 | trigger_new_analysis("file.txt", "file_hash") 143 | 144 | backend.trigger_new_analysis.assert_called_once_with("file.txt", "file_hash") 145 | backend.failed_analysis.assert_called_once_with( 146 | "file.txt", 147 | error_code=MaxRetriesErrorCodes.TRIGGER_NEW_ANALYSIS, 148 | error_msg="Max retries triggering new analysis exceeded", 149 | ) 150 | 151 | 152 | def test_trigger_new_analysis_timeout_max_retries(mock_backend): 153 | """Test trigger_new_analysis with timeout error and max retries reached.""" 154 | backend, handler = mock_backend 155 | backend.trigger_new_analysis.side_effect = TimeoutError("Timeout") 156 | backend.failed_analysis = mock.MagicMock() 157 | with mock.patch("lasuite.malware_detection.tasks.jcop.MalwareDetectionHandler", return_value=handler): 158 | trigger_new_analysis.max_retries = 0 159 | trigger_new_analysis("file.txt", "file_hash") 160 | 161 | backend.trigger_new_analysis.assert_called_once_with("file.txt", "file_hash") 162 | backend.failed_analysis.assert_called_once_with( 163 | "file.txt", 164 | error_code=MaxRetriesErrorCodes.TRIGGER_NEW_ANALYSIS_TIMEOUT, 165 | error_msg="Max retries triggering new analysis exceeded", 166 | ) 167 | 168 | 169 | def test_trigger_new_analysis_with_auth_error_no_retry(mock_backend): 170 | """Test trigger_new_analysis with authentication error (no retry).""" 171 | backend, handler = mock_backend 172 | backend.trigger_new_analysis.side_effect = MalwareDetectionInvalidAuthenticationError("Invalid API key") 173 | 174 | with ( 175 | mock.patch("lasuite.malware_detection.tasks.jcop.MalwareDetectionHandler", return_value=handler), 176 | pytest.raises(MalwareDetectionInvalidAuthenticationError), 177 | ): 178 | trigger_new_analysis("file.txt", "file_hash") 179 | 180 | backend.trigger_new_analysis.assert_called_once_with("file.txt", "file_hash") 181 | backend.failed_analysis.assert_not_called() 182 | -------------------------------------------------------------------------------- /tests/oidc_resource_server/test_clients.py: -------------------------------------------------------------------------------- 1 | """Test for the Resource Server (RS) clients classes.""" 2 | 3 | # pylint: disable=W0212 4 | 5 | from unittest.mock import MagicMock, patch 6 | 7 | import pytest 8 | from joserfc.errors import MissingKeyTypeError 9 | from joserfc.jwk import KeySet, RSAKey 10 | from requests.exceptions import HTTPError 11 | 12 | from lasuite.oidc_resource_server.clients import AuthorizationServerClient, JWTAuthorizationServerClient 13 | 14 | 15 | @pytest.fixture(name="authorization_server_client") 16 | def fixture_authorization_server_client(settings): 17 | """Generate an Authorization Server client.""" 18 | settings.OIDC_OP_URL = "https://auth.example.com/api/v2" 19 | settings.OIDC_VERIFY_SSL = True 20 | settings.OIDC_TIMEOUT = 5 21 | settings.OIDC_PROXY = None 22 | settings.OIDC_OP_INTROSPECTION_ENDPOINT = "https://auth.example.com/api/v2/introspect" 23 | 24 | return AuthorizationServerClient() 25 | 26 | 27 | @pytest.fixture(name="jwt_authorization_server_client") 28 | def fixture_jwt_authorization_server_client(settings): 29 | """Generate an Authorization Server client using JWT.""" 30 | settings.OIDC_OP_URL = "https://auth.example.com/api/v2" 31 | settings.OIDC_VERIFY_SSL = True 32 | settings.OIDC_TIMEOUT = 5 33 | settings.OIDC_PROXY = None 34 | settings.OIDC_OP_INTROSPECTION_ENDPOINT = "https://auth.example.com/api/v2/introspect" 35 | settings.OIDC_OP_JWKS_ENDPOINT = "https://auth.example.com/api/v2/jwks" 36 | 37 | return JWTAuthorizationServerClient() 38 | 39 | 40 | def test_authorization_server_client_initialization(settings): 41 | """Test the AuthorizationServerClient initialization.""" 42 | settings.OIDC_OP_URL = "https://auth.example.com/api/v2" 43 | settings.OIDC_VERIFY_SSL = True 44 | settings.OIDC_TIMEOUT = 5 45 | settings.OIDC_PROXY = None 46 | settings.OIDC_OP_INTROSPECTION_ENDPOINT = "https://auth.example.com/api/v2/introspect" 47 | 48 | new_client = AuthorizationServerClient() 49 | 50 | assert new_client.url == "https://auth.example.com/api/v2" 51 | assert new_client._url_introspection == "https://auth.example.com/api/v2/introspect" 52 | assert new_client._verify_ssl is True 53 | assert new_client._timeout == 5 54 | assert new_client._proxy is None 55 | 56 | 57 | def test_jwt_authorization_server_client_initialization(settings): 58 | """Test the JWTAuthorizationServerClient initialization.""" 59 | settings.OIDC_OP_URL = "https://auth.example.com/api/v2" 60 | settings.OIDC_VERIFY_SSL = True 61 | settings.OIDC_TIMEOUT = 5 62 | settings.OIDC_PROXY = None 63 | settings.OIDC_OP_INTROSPECTION_ENDPOINT = "https://auth.example.com/api/v2/introspect" 64 | settings.OIDC_OP_JWKS_ENDPOINT = "https://auth.example.com/api/v2/jwks" 65 | 66 | new_client = JWTAuthorizationServerClient() 67 | 68 | assert new_client.url == "https://auth.example.com/api/v2" 69 | assert new_client._url_introspection == "https://auth.example.com/api/v2/introspect" 70 | assert new_client._url_jwks == "https://auth.example.com/api/v2/jwks" 71 | assert new_client._verify_ssl is True 72 | assert new_client._timeout == 5 73 | assert new_client._proxy is None 74 | 75 | 76 | def test_introspection_headers(authorization_server_client): 77 | """Test the introspection headers to ensure they match the expected values.""" 78 | assert authorization_server_client._introspection_headers == { 79 | "Content-Type": "application/x-www-form-urlencoded", 80 | "Accept": "application/json", 81 | } 82 | 83 | 84 | def test_jwt_introspection_headers(jwt_authorization_server_client): 85 | """Test the introspection headers to ensure they match the expected values.""" 86 | assert jwt_authorization_server_client._introspection_headers == { 87 | "Content-Type": "application/x-www-form-urlencoded", 88 | "Accept": "application/token-introspection+jwt", 89 | } 90 | 91 | 92 | @patch("requests.post") 93 | def test_get_introspection_success(mock_post, authorization_server_client): 94 | """Test 'get_introspection' method with a successful response.""" 95 | mock_response = MagicMock() 96 | mock_response.raise_for_status.return_value = None 97 | mock_response.text = "introspection response" 98 | mock_post.return_value = mock_response 99 | 100 | result = authorization_server_client.get_introspection("client_id", "client_secret", "token") 101 | assert result == "introspection response" 102 | 103 | mock_post.assert_called_once_with( 104 | "https://auth.example.com/api/v2/introspect", 105 | data={ 106 | "client_id": "client_id", 107 | "client_secret": "client_secret", 108 | "token": "token", 109 | }, 110 | headers={ 111 | "Content-Type": "application/x-www-form-urlencoded", 112 | "Accept": "application/json", 113 | }, 114 | verify=True, 115 | timeout=5, 116 | proxies=None, 117 | ) 118 | 119 | 120 | @patch("requests.post", side_effect=HTTPError()) 121 | # pylint: disable=(unused-argument 122 | def test_get_introspection_error(mock_post, authorization_server_client): 123 | """Test 'get_introspection' method with an HTTPError.""" 124 | with pytest.raises(HTTPError): 125 | authorization_server_client.get_introspection("client_id", "client_secret", "token") 126 | 127 | 128 | @patch("requests.get") 129 | def test_get_jwks_success(mock_get, jwt_authorization_server_client): 130 | """Test 'get_jwks' method with a successful response.""" 131 | mock_response = MagicMock() 132 | mock_response.raise_for_status.return_value = None 133 | mock_response.json.return_value = {"jwks": "foo"} 134 | mock_get.return_value = mock_response 135 | 136 | result = jwt_authorization_server_client.get_jwks() 137 | assert result == {"jwks": "foo"} 138 | 139 | mock_get.assert_called_once_with( 140 | "https://auth.example.com/api/v2/jwks", 141 | verify=jwt_authorization_server_client._verify_ssl, 142 | timeout=jwt_authorization_server_client._timeout, 143 | proxies=jwt_authorization_server_client._proxy, 144 | ) 145 | 146 | 147 | @patch("requests.get") 148 | def test_get_jwks_error(mock_get, jwt_authorization_server_client): 149 | """Test 'get_jwks' method with an HTTPError.""" 150 | mock_response = MagicMock() 151 | mock_response.raise_for_status.side_effect = HTTPError(response=MagicMock(status=500)) 152 | mock_get.return_value = mock_response 153 | 154 | with pytest.raises(HTTPError): 155 | jwt_authorization_server_client.get_jwks() 156 | 157 | 158 | @patch("requests.get") 159 | def test_import_public_keys_valid(mock_get, jwt_authorization_server_client): 160 | """Test 'import_public_keys' method with a successful response.""" 161 | mocked_key = RSAKey.generate_key(2048) 162 | 163 | mock_response = MagicMock() 164 | mock_response.raise_for_status.return_value = None 165 | mock_response.json.return_value = {"keys": [mocked_key.as_dict()]} 166 | mock_get.return_value = mock_response 167 | 168 | response = jwt_authorization_server_client.import_public_keys() 169 | 170 | assert isinstance(response, KeySet) 171 | assert response.as_dict() == KeySet([mocked_key]).as_dict() 172 | 173 | 174 | @patch("requests.get") 175 | def test_import_public_keys_http_error(mock_get, jwt_authorization_server_client): 176 | """Test 'import_public_keys' method with an HTTPError.""" 177 | mock_response = MagicMock() 178 | mock_response.raise_for_status.side_effect = HTTPError(response=MagicMock(status=500)) 179 | mock_get.return_value = mock_response 180 | 181 | with pytest.raises(HTTPError): 182 | jwt_authorization_server_client.import_public_keys() 183 | 184 | 185 | @patch("requests.get") 186 | def test_import_public_keys_empty_jwks(mock_get, jwt_authorization_server_client): 187 | """Test 'import_public_keys' method with empty keys response.""" 188 | jwks1 = KeySet.generate_key_set("RSA", 2048) 189 | jwks1_dict = jwks1.as_dict() 190 | 191 | mock_response = MagicMock() 192 | mock_response.raise_for_status.return_value = None 193 | mock_response.json.return_value = jwks1_dict 194 | mock_get.return_value = mock_response 195 | 196 | response = jwt_authorization_server_client.import_public_keys() 197 | 198 | assert isinstance(response, KeySet) 199 | assert response.as_dict() == { 200 | "keys": jwks1_dict["keys"], 201 | } 202 | 203 | 204 | @patch("requests.get") 205 | def test_import_public_keys_invalid_jwks(mock_get, jwt_authorization_server_client): 206 | """Test 'import_public_keys' method with invalid keys response.""" 207 | mock_response = MagicMock() 208 | mock_response.raise_for_status.return_value = None 209 | mock_response.json.return_value = {"keys": [{"foo": "foo"}]} 210 | mock_get.return_value = mock_response 211 | 212 | with pytest.raises(MissingKeyTypeError, match="Missing key type"): 213 | jwt_authorization_server_client.import_public_keys() 214 | -------------------------------------------------------------------------------- /src/lasuite/oidc_login/middleware.py: -------------------------------------------------------------------------------- 1 | """ 2 | Module to declare a RefreshOIDCAccessToken middleware that extends the 3 | mozilla_django_oidc.middleware.SessionRefresh middleware to refresh the 4 | access token when it expires, based on the OIDC provided refresh token. 5 | This is based on https://github.com/mozilla/mozilla-django-oidc/pull/377 6 | which is still not merged. 7 | """ 8 | 9 | import json 10 | import logging 11 | import time 12 | from urllib.parse import quote, urlencode 13 | 14 | import requests 15 | from django.contrib.auth import BACKEND_SESSION_KEY 16 | from django.http import JsonResponse 17 | from django.urls import reverse 18 | from django.utils.crypto import get_random_string 19 | from django.utils.module_loading import import_string 20 | from mozilla_django_oidc.middleware import SessionRefresh 21 | from rest_framework.status import HTTP_400_BAD_REQUEST, HTTP_401_UNAUTHORIZED 22 | 23 | from lasuite.oidc_login.backends import OIDCAuthenticationBackend, get_oidc_refresh_token, store_tokens 24 | 25 | try: 26 | from mozilla_django_oidc.middleware import ( 27 | RefreshOIDCAccessToken as MozillaRefreshOIDCAccessToken, # noqa F401 28 | ) 29 | 30 | # If the import is successful, raise an error to notify the user that the 31 | # version of mozilla_django_oidc added the expected middleware, and we don't need 32 | # our implementation anymore. 33 | # See https://github.com/mozilla/mozilla-django-oidc/pull/377 34 | raise RuntimeError("This version of mozilla_django_oidc has RefreshOIDCAccessToken") 35 | except ImportError: 36 | pass 37 | 38 | from mozilla_django_oidc.utils import ( 39 | absolutify, 40 | add_state_and_verifier_and_nonce_to_session, 41 | import_from_settings, 42 | ) 43 | 44 | logger = logging.getLogger(__name__) 45 | 46 | 47 | class RefreshOIDCAccessToken(SessionRefresh): 48 | """ 49 | A middleware that will refresh the access token following proper OIDC protocol: 50 | https://auth0.com/docs/tokens/refresh-token/current 51 | This is based on https://github.com/mozilla/mozilla-django-oidc/pull/377 52 | but limited to our needs (YAGNI/KISS). 53 | """ 54 | 55 | def _prepare_reauthorization(self, request): 56 | """ 57 | Construct a new authorization grant request to refresh the session. 58 | Besides constructing the request, the state and nonce included in the 59 | request are registered in the current session in preparation for the 60 | client following through with the authorization flow. 61 | """ 62 | auth_url = self.OIDC_OP_AUTHORIZATION_ENDPOINT 63 | client_id = self.OIDC_RP_CLIENT_ID 64 | state = get_random_string(self.OIDC_STATE_SIZE) 65 | 66 | # Build the parameters as if we were doing a real auth handoff, except 67 | # we also include prompt=none. 68 | auth_params = { 69 | "response_type": "code", 70 | "client_id": client_id, 71 | "redirect_uri": absolutify(request, reverse(self.OIDC_AUTHENTICATION_CALLBACK_URL)), 72 | "state": state, 73 | "scope": self.OIDC_RP_SCOPES, 74 | "prompt": "none", 75 | } 76 | 77 | if self.OIDC_USE_NONCE: 78 | nonce = get_random_string(self.OIDC_NONCE_SIZE) 79 | auth_params.update({"nonce": nonce}) 80 | 81 | # Register the one-time parameters in the session 82 | add_state_and_verifier_and_nonce_to_session(request, state, auth_params) 83 | request.session["oidc_login_next"] = request.get_full_path() 84 | 85 | query = urlencode(auth_params, quote_via=quote) 86 | return f"{auth_url}?{query}" 87 | 88 | def is_refreshable_url(self, request): 89 | """ 90 | Take a request and returns whether it triggers a refresh examination. 91 | 92 | In the original implementation [1], the request method is checked to be 93 | GET. This is relevant as if the session has expired, the user will be 94 | redirected to a login page, that can be problematic for XHR requests. 95 | Like the `finish` method documentation explains, in our implementation 96 | we consider all requests as XHR requests, so in our case we can safely 97 | ignore the request method check as in case of expired token, 98 | a 401 status code will be always returned. 99 | 100 | 1. https://github.com/mozilla/mozilla-django-oidc/blob/774b140/mozilla_django_oidc/middleware.py#L96-L117 101 | """ 102 | # Do not attempt to refresh the session if the OIDC backend is not used 103 | backend_session = request.session.get(BACKEND_SESSION_KEY) 104 | is_oidc_enabled = True 105 | if backend_session: 106 | auth_backend = import_string(backend_session) 107 | is_oidc_enabled = issubclass(auth_backend, OIDCAuthenticationBackend) 108 | 109 | return ( 110 | request.user.is_authenticated 111 | and is_oidc_enabled 112 | and request.path not in self.exempt_urls 113 | and not any(pattern.match(request.path) for pattern in self.exempt_url_patterns) 114 | ) 115 | 116 | def is_expired(self, request): 117 | """Check whether the access token is expired and needs to be refreshed.""" 118 | if not self.is_refreshable_url(request): 119 | logger.debug("request is not refreshable") 120 | return False 121 | 122 | expiration = request.session.get("oidc_token_expiration", 0) 123 | now = time.time() 124 | if expiration > now: 125 | # The id_token is still valid, so we don't have to do anything. 126 | logger.debug("id token is still valid (%s > %s)", expiration, now) 127 | return False 128 | 129 | return True 130 | 131 | def finish(self, request, prompt_reauth=True): 132 | """ 133 | Finish request handling and handle sending downstream responses for XHR. 134 | 135 | This function should only be run if the session is determined to 136 | be expired. 137 | Almost all XHR request handling in client-side code struggles 138 | with redirects since redirecting to a page where the user 139 | is supposed to do something is extremely unlikely to work 140 | in an XHR request. Make a special response for these kinds 141 | of requests. 142 | The use of 403 Forbidden is to match the fact that this 143 | middleware doesn't really want the user in if they don't 144 | refresh their session. 145 | WARNING: this varies from the original implementation: 146 | - to return a 401 status code 147 | - to consider all requests as XHR requests 148 | """ 149 | xhr_response_json = {"error": "the authentication session has expired"} 150 | if prompt_reauth: 151 | # The id_token has expired, so we have to re-authenticate silently. 152 | refresh_url = self._prepare_reauthorization(request) 153 | xhr_response_json["refresh_url"] = refresh_url 154 | 155 | xhr_response = JsonResponse(xhr_response_json, status=HTTP_401_UNAUTHORIZED) 156 | if "refresh_url" in xhr_response_json: 157 | xhr_response["refresh_url"] = xhr_response_json["refresh_url"] 158 | return xhr_response 159 | 160 | def process_request(self, request): # noqa: PLR0911 # pylint: disable=too-many-return-statements 161 | """Process the request and refresh the access token if necessary.""" 162 | if not self.is_expired(request): 163 | return None 164 | 165 | token_url = self.get_settings("OIDC_OP_TOKEN_ENDPOINT") 166 | client_id = self.get_settings("OIDC_RP_CLIENT_ID") 167 | client_secret = self.get_settings("OIDC_RP_CLIENT_SECRET") 168 | refresh_token = get_oidc_refresh_token(request.session) 169 | 170 | if not refresh_token: 171 | logger.debug("no refresh token stored") 172 | return self.finish(request, prompt_reauth=True) 173 | 174 | token_payload = { 175 | "grant_type": "refresh_token", 176 | "client_id": client_id, 177 | "client_secret": client_secret, 178 | "refresh_token": refresh_token, 179 | } 180 | 181 | req_auth = None 182 | if self.get_settings("OIDC_TOKEN_USE_BASIC_AUTH", False): 183 | # supported in https://github.com/mozilla/mozilla-django-oidc/pull/377 184 | # but we don't need it, so enforce error here. 185 | raise RuntimeError("OIDC_TOKEN_USE_BASIC_AUTH is not supported") 186 | 187 | try: 188 | response = requests.post( 189 | token_url, 190 | auth=req_auth, 191 | data=token_payload, 192 | verify=import_from_settings("OIDC_VERIFY_SSL", True), 193 | timeout=import_from_settings("OIDC_TIMEOUT", 3), 194 | ) 195 | response.raise_for_status() 196 | token_info = response.json() 197 | except requests.exceptions.Timeout: 198 | logger.debug("timed out refreshing access token") 199 | # Don't prompt for reauth as this could be a temporary problem 200 | return self.finish(request, prompt_reauth=False) 201 | except requests.exceptions.HTTPError as exc: 202 | status_code = exc.response.status_code 203 | logger.debug("http error %s when refreshing access token", status_code) 204 | # OAuth error response will be a 400 for various situations, including 205 | # an expired token. https://datatracker.ietf.org/doc/html/rfc6749#section-5.2 206 | return self.finish(request, prompt_reauth=status_code == HTTP_400_BAD_REQUEST) 207 | except json.JSONDecodeError: 208 | logger.debug("malformed response when refreshing access token") 209 | # Don't prompt for reauth as this could be a temporary problem 210 | return self.finish(request, prompt_reauth=False) 211 | except Exception as exc: # pylint: disable=broad-except 212 | logger.exception("unknown error occurred when refreshing access token: %s", exc) 213 | # Don't prompt for reauth as this could be a temporary problem 214 | return self.finish(request, prompt_reauth=False) 215 | 216 | # Until we can properly validate an ID token on the refresh response 217 | # per the spec[1], we intentionally drop the id_token. 218 | # [1]: https://openid.net/specs/openid-connect-core-1_0.html#RefreshTokenResponse 219 | id_token = None 220 | access_token = token_info.get("access_token") 221 | refresh_token = token_info.get("refresh_token") 222 | store_tokens(request.session, access_token, id_token, refresh_token) 223 | 224 | return None 225 | -------------------------------------------------------------------------------- /src/lasuite/malware_detection/backends/jcop.py: -------------------------------------------------------------------------------- 1 | """Module contains the JCOP backends for the malware detection system.""" 2 | 3 | import hashlib 4 | import logging 5 | from http import HTTPStatus 6 | 7 | import requests 8 | from django.core.files.storage import default_storage 9 | from django.db import transaction 10 | from django.db.models import Q 11 | from requests_toolbelt import MultipartEncoder 12 | 13 | from ..enums import ReportStatus 14 | from ..exceptions import MalwareDetectionInvalidAuthenticationError 15 | from ..models import MalwareDetection, MalwareDetectionStatus 16 | from ..tasks.jcop import analyse_file_async, trigger_new_analysis 17 | from .base import BaseBackend 18 | 19 | logger = logging.getLogger(__name__) 20 | 21 | 22 | class JCOPBackend(BaseBackend): 23 | """A backend that uses JCOP to detect malware.""" 24 | 25 | def __init__( # noqa: PLR0913 26 | self, 27 | base_url: str, 28 | api_key: str, 29 | callback_path: str, 30 | result_timeout: int = 30, 31 | submit_timeout: int = 10 * 60, 32 | max_processing_files: int = 15, 33 | ): 34 | """Configure the JCOP backend.""" 35 | super().__init__( 36 | callback_path=callback_path, 37 | max_processing_files=max_processing_files, 38 | ) 39 | 40 | self.base_url = base_url 41 | self.api_key = api_key 42 | self.result_timeout = result_timeout 43 | self.submit_timeout = submit_timeout 44 | 45 | def analyse_file(self, file_path: str, **kwargs) -> None: 46 | """Trigger a process to analyse a file using JCOP service.""" 47 | if not default_storage.exists(file_path): 48 | raise FileNotFoundError(f"File {file_path} not found") 49 | 50 | if self.max_processing_files == 0: 51 | # if max_processing_files is 0, we don't want to create a detection record 52 | # we just want to analyse the file asynchronously 53 | analyse_file_async.delay( 54 | file_path, 55 | **kwargs, 56 | ) 57 | return 58 | 59 | MalwareDetection.objects.create( 60 | path=file_path, 61 | status=MalwareDetectionStatus.PENDING, 62 | backend=self.backend_name, 63 | parameters=kwargs, 64 | ) 65 | 66 | self.launch_next_analysis() 67 | 68 | def reschedule_processing_task(self, malware_detection_record: MalwareDetection) -> None: 69 | """Reschedule the processing task for a malware detection record.""" 70 | if malware_detection_record.status != MalwareDetectionStatus.PROCESSING: 71 | return 72 | 73 | if malware_detection_record.backend != self.backend_name: 74 | return 75 | 76 | # assert first the file still exists in the system 77 | if not default_storage.exists(malware_detection_record.path): 78 | logger.info("File %s not found when rescheduling processing task", malware_detection_record.path) 79 | malware_detection_record.delete() 80 | return 81 | 82 | analyse_file_async.delay( 83 | malware_detection_record.path, 84 | **malware_detection_record.parameters, 85 | ) 86 | 87 | def launch_next_analysis(self) -> None: 88 | """Launch the next pending analysis.""" 89 | if ( 90 | MalwareDetection.objects.filter(status=MalwareDetectionStatus.PROCESSING).count() 91 | >= self.max_processing_files 92 | ): 93 | return 94 | 95 | with transaction.atomic(): 96 | first_pending_detection = ( 97 | MalwareDetection.objects.select_for_update() 98 | .filter(status=MalwareDetectionStatus.PENDING) 99 | .order_by("created_at") 100 | .first() 101 | ) 102 | if first_pending_detection is None: 103 | return 104 | first_pending_detection.status = MalwareDetectionStatus.PROCESSING 105 | first_pending_detection.save(update_fields=["status"]) 106 | 107 | analyse_file_async.delay( 108 | first_pending_detection.path, 109 | **first_pending_detection.parameters, 110 | ) 111 | 112 | def _update_detection_file_hash(self, file_path: str, file_hash: str) -> None: 113 | """Update the file hash for a detection record.""" 114 | MalwareDetection.objects.filter( 115 | path=file_path, 116 | ).filter(~Q(file_hash=file_hash)).update(file_hash=file_hash) 117 | 118 | def delete_detection(self, file_path: str) -> None: 119 | """Delete a detection record.""" 120 | try: 121 | detection = MalwareDetection.objects.get(path=file_path) 122 | except MalwareDetection.DoesNotExist: 123 | logger.warning("Detection %s not found", file_path) 124 | return 125 | else: 126 | detection.delete() 127 | 128 | def failed_analysis( 129 | self, 130 | file_path: str, 131 | file_hash: str, 132 | error_code: int, 133 | error_msg: str, 134 | status: ReportStatus | None = None, 135 | **kwargs, 136 | ) -> None: 137 | """Handle a failed analysis.""" 138 | try: 139 | detection = MalwareDetection.objects.get(path=file_path) 140 | except MalwareDetection.DoesNotExist: 141 | logger.warning("Detection %s not found", file_path) 142 | else: 143 | detection.status = MalwareDetectionStatus.FAILED 144 | detection.error_code = error_code 145 | detection.error_msg = error_msg 146 | detection.save(update_fields=["status", "error_code", "error_msg"]) 147 | 148 | self.launch_next_analysis() 149 | self.callback( 150 | file_path, 151 | status if status is not None else ReportStatus.UNKNOWN, 152 | error_info={ 153 | "error": error_msg, 154 | "error_code": error_code, 155 | "file_hash": file_hash, 156 | }, 157 | **kwargs, 158 | ) 159 | 160 | def succeed_analysis(self, file_path: str, **kwargs) -> None: 161 | """Handle a successful analysis.""" 162 | self.delete_detection(file_path) 163 | self.launch_next_analysis() 164 | self.callback(file_path, ReportStatus.SAFE, error_info={}, **kwargs) 165 | 166 | def check_analysis(self, file_path: str, file_hash: str | None = None, **kwargs) -> bool: 167 | """Start the analysis process for a file.""" 168 | if file_hash is None: 169 | with default_storage.open(file_path, "rb") as file: 170 | file_hash = hashlib.file_digest(file, "sha256").hexdigest() 171 | self._update_detection_file_hash(file_path, file_hash) 172 | 173 | try: 174 | # try if the file as already been tested 175 | response = requests.get( 176 | f"{self.base_url}/results/{file_hash}", 177 | headers={ 178 | "X-Auth-Token": self.api_key, 179 | "Accept": "application/json", 180 | }, 181 | timeout=self.result_timeout, 182 | ) 183 | except requests.exceptions.RequestException as exc: 184 | logger.error("Error getting cache result for file %s: %s", file_path, exc) 185 | raise 186 | 187 | if response.status_code == HTTPStatus.NOT_FOUND: 188 | # start a new analysis 189 | trigger_new_analysis.delay( 190 | file_path, 191 | file_hash, 192 | **kwargs, 193 | ) 194 | return False 195 | 196 | if response.status_code == HTTPStatus.UNAUTHORIZED: 197 | self.failed_analysis( 198 | file_path, file_hash, response.status_code, "Invalid API key", status=ReportStatus.UNKNOWN, **kwargs 199 | ) 200 | raise MalwareDetectionInvalidAuthenticationError() 201 | 202 | if response.status_code == HTTPStatus.OK: 203 | content = response.json() 204 | if content.get("done", False) is False: 205 | # the analysis is not done yet, retry later 206 | return True 207 | 208 | is_malware = content.get("is_malware") 209 | if is_malware is True or content.get("error_code"): 210 | status = ReportStatus.UNSAFE if is_malware else ReportStatus.UNKNOWN 211 | self.failed_analysis( 212 | file_path, 213 | file_hash, 214 | content.get("error_code", 5000), 215 | content.get("error", "malware detected"), 216 | status, 217 | **kwargs, 218 | ) 219 | return False 220 | 221 | if is_malware is False: 222 | self.succeed_analysis(file_path, **kwargs) 223 | return False 224 | 225 | # Any other case, call the callback with an unknown error 226 | self.failed_analysis( 227 | file_path, file_hash, response.status_code, "Unknown treatment", status=ReportStatus.UNKNOWN, **kwargs 228 | ) 229 | return False 230 | 231 | def trigger_new_analysis(self, file_path: str, file_hash: str, **kwargs): 232 | """Trigger a new analysis for a file.""" 233 | self._update_detection_file_hash(file_path, file_hash) 234 | with default_storage.open(file_path, "rb") as file: 235 | encoder = MultipartEncoder( 236 | fields={ 237 | "file": (file.name, file), 238 | } 239 | ) 240 | try: 241 | response = requests.post( 242 | f"{self.base_url}/submit", 243 | headers={ 244 | "X-Auth-Token": self.api_key, 245 | "Accept": "application/json", 246 | "Content-Type": encoder.content_type, 247 | }, 248 | data=encoder, 249 | timeout=(30, self.submit_timeout), 250 | ) 251 | except requests.exceptions.RequestException: 252 | logger.error("Error while sending file %s to JCOP with hash %s", file_path, file_hash) 253 | raise 254 | 255 | if response.status_code == HTTPStatus.OK: 256 | content = response.json() 257 | analyse_file_async.apply_async( 258 | countdown=5, 259 | args=(file_path,), 260 | kwargs={"file_hash": content["id"], **kwargs}, 261 | ) 262 | return 263 | 264 | if response.status_code == HTTPStatus.UNAUTHORIZED: 265 | self.failed_analysis( 266 | file_path, file_hash, response.status_code, "Invalid API key", status=ReportStatus.UNKNOWN, **kwargs 267 | ) 268 | raise MalwareDetectionInvalidAuthenticationError() 269 | 270 | if response.status_code == HTTPStatus.REQUEST_TIMEOUT: 271 | raise TimeoutError() 272 | 273 | if response.status_code == HTTPStatus.REQUEST_ENTITY_TOO_LARGE: 274 | self.failed_analysis( 275 | file_path, file_hash, response.status_code, "File too large", status=ReportStatus.UNKNOWN, **kwargs 276 | ) 277 | return 278 | 279 | self.failed_analysis( 280 | file_path, file_hash, response.status_code, "Unknown treatment", status=ReportStatus.UNKNOWN, **kwargs 281 | ) 282 | -------------------------------------------------------------------------------- /tests/oidc_login/test_middleware.py: -------------------------------------------------------------------------------- 1 | """Tests for the RefreshOIDCAccessToken middleware.""" 2 | 3 | import time 4 | from unittest.mock import MagicMock 5 | 6 | import factories 7 | import pytest 8 | import requests.exceptions 9 | import responses 10 | from cryptography.fernet import Fernet 11 | from django.contrib.auth.models import AnonymousUser 12 | from django.contrib.sessions.middleware import SessionMiddleware 13 | from django.http import HttpResponse, JsonResponse 14 | from django.test import RequestFactory 15 | 16 | from lasuite.oidc_login.backends import get_cipher_suite, get_oidc_refresh_token, store_oidc_refresh_token 17 | from lasuite.oidc_login.middleware import RefreshOIDCAccessToken 18 | 19 | pytestmark = pytest.mark.django_db 20 | 21 | 22 | @pytest.fixture(name="oidc_settings") 23 | def fixture_oidc_settings(settings): 24 | """Fixture to configure OIDC settings for the tests.""" 25 | settings.OIDC_OP_TOKEN_ENDPOINT = "https://auth.example.com/token" 26 | settings.OIDC_OP_AUTHORIZATION_ENDPOINT = "https://auth.example.com/authorize" 27 | settings.OIDC_RP_CLIENT_ID = "client_id" 28 | settings.OIDC_RP_CLIENT_SECRET = "client_secret" 29 | settings.OIDC_AUTHENTICATION_CALLBACK_URL = "oidc_authentication_callback" 30 | settings.OIDC_RP_SCOPES = "openid email" 31 | settings.OIDC_USE_NONCE = True 32 | settings.OIDC_STATE_SIZE = 32 33 | settings.OIDC_NONCE_SIZE = 32 34 | settings.OIDC_VERIFY_SSL = True 35 | settings.OIDC_TOKEN_USE_BASIC_AUTH = False 36 | settings.OIDC_STORE_ACCESS_TOKEN = True 37 | settings.OIDC_STORE_REFRESH_TOKEN = True 38 | settings.OIDC_STORE_REFRESH_TOKEN_KEY = Fernet.generate_key() 39 | 40 | get_cipher_suite.cache_clear() 41 | 42 | yield settings 43 | 44 | get_cipher_suite.cache_clear() 45 | 46 | 47 | def test_anonymous_user(oidc_settings): # pylint: disable=unused-argument 48 | """ 49 | When the user is not authenticated, this 50 | is not the purpose of the middleware to manage anything. 51 | """ 52 | request = RequestFactory().get("/test") 53 | request.user = AnonymousUser() 54 | 55 | get_response = MagicMock() 56 | session_middleware = SessionMiddleware(get_response) 57 | session_middleware.process_request(request) 58 | 59 | middleware = RefreshOIDCAccessToken(get_response) 60 | response = middleware.process_request(request) 61 | assert response is None 62 | 63 | 64 | def test_no_refresh_token(oidc_settings): # pylint: disable=unused-argument 65 | """ 66 | When the session does not contain a refresh token, 67 | the middleware should return a 401 response containing 68 | the URL to authenticate again. 69 | """ 70 | user = factories.UserFactory() 71 | 72 | request = RequestFactory().get("/test") 73 | request.user = user 74 | 75 | get_response = MagicMock() 76 | session_middleware = SessionMiddleware(get_response) 77 | session_middleware.process_request(request) 78 | 79 | request.session["oidc_access_token"] = "expired_token" 80 | request.session["oidc_token_expiration"] = time.time() - 100 81 | 82 | middleware = RefreshOIDCAccessToken(get_response) 83 | response = middleware.process_request(request) 84 | assert isinstance(response, JsonResponse) 85 | assert response.status_code == 401 86 | assert response.has_header("refresh_url") 87 | assert response["refresh_url"].startswith("https://auth.example.com/authorize") 88 | 89 | 90 | def test_basic_auth_disabled(oidc_settings): # pylint: disable=unused-argument 91 | """We don't support OIDC_TOKEN_USE_BASIC_AUTH.""" 92 | oidc_settings.OIDC_TOKEN_USE_BASIC_AUTH = True 93 | 94 | user = factories.UserFactory() 95 | 96 | request = RequestFactory().get("/test") 97 | request.user = user 98 | 99 | get_response = MagicMock() 100 | session_middleware = SessionMiddleware(get_response) 101 | session_middleware.process_request(request) 102 | 103 | request.session["oidc_access_token"] = "old_token" 104 | store_oidc_refresh_token(request.session, "refresh_token") 105 | request.session["oidc_token_expiration"] = time.time() - 100 106 | request.session.save() 107 | 108 | middleware = RefreshOIDCAccessToken(get_response) 109 | with pytest.raises(RuntimeError) as excinfo: 110 | middleware.process_request(request) 111 | 112 | assert str(excinfo.value) == "OIDC_TOKEN_USE_BASIC_AUTH is not supported" 113 | 114 | 115 | @responses.activate 116 | @pytest.mark.parametrize("http_method", ["get", "post", "put", "patch", "delete", "head", "options"]) 117 | def test_successful_token_refresh(oidc_settings, http_method): # pylint: disable=unused-argument 118 | """Test that the middleware successfully refreshes the token for any HTTP method.""" 119 | user = factories.UserFactory() 120 | 121 | request = getattr(RequestFactory(), http_method)("/test") 122 | request.user = user 123 | 124 | get_response = MagicMock() 125 | session_middleware = SessionMiddleware(get_response) 126 | session_middleware.process_request(request) 127 | 128 | request.session["oidc_access_token"] = "old_token" 129 | store_oidc_refresh_token(request.session, "refresh_token") 130 | request.session["oidc_token_expiration"] = time.time() - 100 131 | request.session.save() 132 | 133 | responses.add( 134 | responses.POST, 135 | "https://auth.example.com/token", 136 | json={"access_token": "new_token", "refresh_token": "new_refresh_token"}, 137 | status=200, 138 | ) 139 | 140 | middleware = RefreshOIDCAccessToken(get_response) 141 | response = middleware.process_request(request) 142 | request.session.save() 143 | 144 | assert response is None 145 | assert request.session["oidc_access_token"] == "new_token" 146 | assert get_oidc_refresh_token(request.session) == "new_refresh_token" 147 | 148 | 149 | def test_non_expired_token(oidc_settings): # pylint: disable=unused-argument 150 | """Test that the middleware does nothing when the token is not expired.""" 151 | user = factories.UserFactory() 152 | 153 | request = RequestFactory().get("/test") 154 | request.user = user 155 | 156 | get_response = MagicMock() 157 | session_middleware = SessionMiddleware(get_response) 158 | session_middleware.process_request(request) 159 | request.session["oidc_access_token"] = ("valid_token",) 160 | request.session["oidc_token_expiration"] = time.time() + 3600 161 | request.session.save() 162 | 163 | middleware = RefreshOIDCAccessToken(get_response) 164 | 165 | response = middleware.process_request(request) 166 | assert response is None 167 | 168 | 169 | @responses.activate 170 | @pytest.mark.parametrize("http_method", ["get", "post", "put", "patch", "delete", "head", "options"]) 171 | def test_refresh_token_request_timeout(oidc_settings, http_method): # pylint: disable=unused-argument 172 | """ 173 | Test that the middleware returns a 401 response when the token 174 | refresh request times out for any HTTP method. 175 | """ 176 | user = factories.UserFactory() 177 | request = getattr(RequestFactory(), http_method)("/test") 178 | request.user = user 179 | 180 | get_response = MagicMock() 181 | session_middleware = SessionMiddleware(get_response) 182 | session_middleware.process_request(request) 183 | request.session["oidc_access_token"] = "old_token" 184 | store_oidc_refresh_token(request.session, "refresh_token") 185 | request.session["oidc_token_expiration"] = time.time() - 100 186 | request.session.save() 187 | 188 | responses.add( 189 | responses.POST, 190 | "https://auth.example.com/token", 191 | body=requests.exceptions.Timeout("timeout"), 192 | ) 193 | 194 | middleware = RefreshOIDCAccessToken(get_response) 195 | response = middleware.process_request(request) 196 | assert isinstance(response, HttpResponse) 197 | assert response.status_code == 401 198 | assert not response.has_header("refresh_url") 199 | 200 | 201 | @responses.activate 202 | def test_refresh_token_request_error_400(oidc_settings): # pylint: disable=unused-argument 203 | """ 204 | Test that the middleware returns a 401 response when the token 205 | refresh request returns a 400 error. 206 | """ 207 | user = factories.UserFactory() 208 | request = RequestFactory().get("/test") 209 | request.user = user 210 | 211 | get_response = MagicMock() 212 | session_middleware = SessionMiddleware(get_response) 213 | session_middleware.process_request(request) 214 | request.session["oidc_access_token"] = "old_token" 215 | store_oidc_refresh_token(request.session, "refresh_token") 216 | request.session["oidc_token_expiration"] = time.time() - 100 217 | request.session.save() 218 | 219 | responses.add( 220 | responses.POST, 221 | "https://auth.example.com/token", 222 | json={"error": "invalid_grant"}, 223 | status=400, 224 | ) 225 | 226 | middleware = RefreshOIDCAccessToken(get_response) 227 | response = middleware.process_request(request) 228 | assert isinstance(response, HttpResponse) 229 | assert response.status_code == 401 230 | assert response.has_header("refresh_url") 231 | assert response["refresh_url"].startswith("https://auth.example.com/authorize") 232 | 233 | 234 | @responses.activate 235 | def test_refresh_token_request_error(oidc_settings): # pylint: disable=unused-argument 236 | """ 237 | Test that the middleware returns a 401 response when 238 | the token refresh request returns a 404 error. 239 | """ 240 | user = factories.UserFactory() 241 | request = RequestFactory().get("/test") 242 | request.user = user 243 | 244 | get_response = MagicMock() 245 | session_middleware = SessionMiddleware(get_response) 246 | session_middleware.process_request(request) 247 | request.session["oidc_access_token"] = "old_token" 248 | store_oidc_refresh_token(request.session, "refresh_token") 249 | request.session["oidc_token_expiration"] = time.time() - 100 250 | request.session.save() 251 | 252 | responses.add( 253 | responses.POST, 254 | "https://auth.example.com/token", 255 | json={"error": "invalid_grant"}, 256 | status=404, 257 | ) 258 | 259 | middleware = RefreshOIDCAccessToken(get_response) 260 | response = middleware.process_request(request) 261 | assert isinstance(response, HttpResponse) 262 | assert response.status_code == 401 263 | assert not response.has_header("refresh_url") 264 | 265 | 266 | @responses.activate 267 | def test_refresh_token_request_malformed_json_error(oidc_settings): # pylint: disable=unused-argument 268 | """ 269 | Test that the middleware returns a 401 response 270 | when the token refresh request returns malformed JSON. 271 | """ 272 | user = factories.UserFactory() 273 | request = RequestFactory().get("/test") 274 | request.user = user 275 | 276 | get_response = MagicMock() 277 | session_middleware = SessionMiddleware(get_response) 278 | session_middleware.process_request(request) 279 | request.session["oidc_access_token"] = "old_token" 280 | store_oidc_refresh_token(request.session, "refresh_token") 281 | request.session["oidc_token_expiration"] = time.time() - 100 282 | request.session.save() 283 | 284 | responses.add( 285 | responses.POST, 286 | "https://auth.example.com/token", 287 | body="malformed json", 288 | status=200, 289 | ) 290 | 291 | middleware = RefreshOIDCAccessToken(get_response) 292 | response = middleware.process_request(request) 293 | assert isinstance(response, HttpResponse) 294 | assert response.status_code == 401 295 | assert not response.has_header("refresh_url") 296 | 297 | 298 | @responses.activate 299 | def test_refresh_token_request_exception(oidc_settings): # pylint: disable=unused-argument 300 | """ 301 | Test that the middleware returns a 401 response 302 | when the token refresh request raises an exception. 303 | """ 304 | user = factories.UserFactory() 305 | request = RequestFactory().get("/test") 306 | request.user = user 307 | 308 | get_response = MagicMock() 309 | session_middleware = SessionMiddleware(get_response) 310 | session_middleware.process_request(request) 311 | request.session["oidc_access_token"] = "old_token" 312 | store_oidc_refresh_token(request.session, "refresh_token") 313 | request.session["oidc_token_expiration"] = time.time() - 100 314 | request.session.save() 315 | 316 | responses.add( 317 | responses.POST, 318 | "https://auth.example.com/token", 319 | body={"error": "invalid_grant"}, # invalid format dict 320 | status=200, 321 | ) 322 | 323 | middleware = RefreshOIDCAccessToken(get_response) 324 | response = middleware.process_request(request) 325 | assert isinstance(response, HttpResponse) 326 | assert response.status_code == 401 327 | assert not response.has_header("refresh_url") 328 | --------------------------------------------------------------------------------