mirror of
https://github.com/suitenumerique/meet.git
synced 2026-10-14 09:01:21 +00:00
Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b0d421f055 | |||
| d930fa1ba6 | |||
| 6f1414b34e | |||
| 0194f7f3f3 | |||
| 0bde103911 |
@@ -22,6 +22,9 @@
|
|||||||
# See http://jorisroovers.github.io/gitlint/user_defined_rules for details
|
# See http://jorisroovers.github.io/gitlint/user_defined_rules for details
|
||||||
extra-path=gitlint/
|
extra-path=gitlint/
|
||||||
|
|
||||||
|
# Use "search" semantics for regexes (gitlint's future default)
|
||||||
|
regex-style-search=True
|
||||||
|
|
||||||
# [title-max-length]
|
# [title-max-length]
|
||||||
# line-length=80
|
# line-length=80
|
||||||
|
|
||||||
|
|||||||
@@ -10,8 +10,16 @@ and this project adheres to
|
|||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
|
- ⚡️(backend) add UPPER(email) index for case-insensitive user lookups
|
||||||
|
- ✨(backend) authenticate external API calls with Menshen exchanged tokens
|
||||||
|
|
||||||
|
## [1.35.0] - 2026-10-13
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
- ✨(frontend) let users pick the recording encoding
|
- ✨(frontend) let users pick the recording encoding
|
||||||
- ✨(backend) introduce a token exchange endpoint for iframe embeds
|
- ✨(backend) introduce a token exchange endpoint for iframe embeds
|
||||||
|
- ⚡️(backend) send recording notification emails from a Celery task
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
|
|||||||
@@ -113,6 +113,7 @@ DJANGO_EMAIL_FROM=<your email address>
|
|||||||
|
|
||||||
#DJANGO_EMAIL_USE_TLS=true # A flag to enable or disable TLS for email sending.
|
#DJANGO_EMAIL_USE_TLS=true # A flag to enable or disable TLS for email sending.
|
||||||
#DJANGO_EMAIL_USE_SSL=true # A flag to enable or disable SSL for email sending.
|
#DJANGO_EMAIL_USE_SSL=true # A flag to enable or disable SSL for email sending.
|
||||||
|
#DJANGO_EMAIL_TIMEOUT=30 # Seconds before a stalled SMTP connection times out.
|
||||||
|
|
||||||
|
|
||||||
DJANGO_EMAIL_BRAND_NAME=<brand name used in email templates> # e.g. "La Suite Numérique"
|
DJANGO_EMAIL_BRAND_NAME=<brand name used in email templates> # e.g. "La Suite Numérique"
|
||||||
|
|||||||
@@ -357,6 +357,7 @@ These are the environmental options available on meet backend.
|
|||||||
| DJANGO_EMAIL_PORT | Port to connect to the email server | |
|
| DJANGO_EMAIL_PORT | Port to connect to the email server | |
|
||||||
| DJANGO_EMAIL_USE_TLS | Enable TLS on email connection | false |
|
| DJANGO_EMAIL_USE_TLS | Enable TLS on email connection | false |
|
||||||
| DJANGO_EMAIL_USE_SSL | Enable SSL on email connection | false |
|
| DJANGO_EMAIL_USE_SSL | Enable SSL on email connection | false |
|
||||||
|
| DJANGO_EMAIL_TIMEOUT | Seconds before a stalled SMTP connection times out | 30 |
|
||||||
| DJANGO_EMAIL_FROM | Email from account | from@example.com |
|
| DJANGO_EMAIL_FROM | Email from account | from@example.com |
|
||||||
| EMAIL_BRAND_NAME | Email branding name | |
|
| EMAIL_BRAND_NAME | Email branding name | |
|
||||||
| EMAIL_SUPPORT_EMAIL | Support email address | |
|
| EMAIL_SUPPORT_EMAIL | Support email address | |
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "agents"
|
name = "agents"
|
||||||
version = "1.34.0"
|
version = "1.35.0"
|
||||||
requires-python = ">=3.12"
|
requires-python = ">=3.12"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"livekit-agents==1.7.0",
|
"livekit-agents==1.7.0",
|
||||||
|
|||||||
Generated
+1
-1
@@ -9,7 +9,7 @@ resolution-markers = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "agents"
|
name = "agents"
|
||||||
version = "1.34.0"
|
version = "1.35.0"
|
||||||
source = { virtual = "." }
|
source = { virtual = "." }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "boto3" },
|
{ name = "boto3" },
|
||||||
|
|||||||
@@ -82,9 +82,6 @@ class OIDCAuthenticationBackend(LaSuiteOIDCAuthenticationBackend):
|
|||||||
"User info contained an invalid sub claim"
|
"User info contained an invalid sub claim"
|
||||||
) from err
|
) from err
|
||||||
|
|
||||||
if len(sub) > 255:
|
|
||||||
raise SuspiciousOperation("User info contained an invalid sub claim")
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
return User.objects.get(sub=sub)
|
return User.objects.get(sub=sub)
|
||||||
except User.DoesNotExist:
|
except User.DoesNotExist:
|
||||||
|
|||||||
@@ -4,21 +4,107 @@
|
|||||||
# ruff: noqa: PLR0913
|
# ruff: noqa: PLR0913
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
from dataclasses import asdict
|
||||||
|
from functools import lru_cache
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.contrib.auth import get_user_model
|
from django.contrib.auth import get_user_model
|
||||||
from django.core.exceptions import SuspiciousOperation
|
from django.core.exceptions import SuspiciousOperation, ValidationError
|
||||||
|
|
||||||
|
import requests
|
||||||
from lasuite.oidc_resource_server.backend import ResourceServerBackend as LaSuiteBackend
|
from lasuite.oidc_resource_server.backend import ResourceServerBackend as LaSuiteBackend
|
||||||
|
from menshen_client import Configuration, IntrospectionRequest, TokenExchangeClient
|
||||||
|
from menshen_client.exceptions import ResponseParsingError
|
||||||
from rest_framework import authentication, exceptions
|
from rest_framework import authentication, exceptions
|
||||||
|
|
||||||
from core.models import Application
|
from core.models import Application, ApplicationScope
|
||||||
from core.services import jwt_token
|
from core.services import jwt_token
|
||||||
|
from core.validators import sub_validator
|
||||||
|
|
||||||
User = get_user_model()
|
User = get_user_model()
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def get_token(request, token_type):
|
||||||
|
"""Extract the token from the Authorization header.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
request: The incoming request
|
||||||
|
token_type: Expected Authorization scheme (e.g. Bearer), matched
|
||||||
|
case-insensitively
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Token string, or None if the request carries no token of this type
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
AuthenticationFailed: If the Authorization header is malformed
|
||||||
|
"""
|
||||||
|
|
||||||
|
auth_header = authentication.get_authorization_header(request).split()
|
||||||
|
|
||||||
|
if not auth_header or auth_header[0].lower() != token_type.lower().encode():
|
||||||
|
return None
|
||||||
|
|
||||||
|
if len(auth_header) != 2:
|
||||||
|
logger.warning("Invalid token header format")
|
||||||
|
raise exceptions.AuthenticationFailed("Invalid token header.")
|
||||||
|
|
||||||
|
try:
|
||||||
|
return auth_header[1].decode("utf-8")
|
||||||
|
except UnicodeError as e:
|
||||||
|
logger.warning("Token decode error: %s", e)
|
||||||
|
raise exceptions.AuthenticationFailed("Invalid token encoding.") from e
|
||||||
|
|
||||||
|
|
||||||
|
def get_or_create_user_by_sub(sub, email=None):
|
||||||
|
"""Retrieve the user matching a token subject, creating it if allowed.
|
||||||
|
|
||||||
|
Shared by the resource server backends, so that tokens introspected with the
|
||||||
|
OIDC provider or with Menshen resolve users the same way.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
sub: Subject identifier from the introspected token
|
||||||
|
email: Email to store on a newly created user
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
User instance, or None if unknown and OIDC_CREATE_USER is unset
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
SuspiciousOperation: If the sub is invalid, the email of a user to create
|
||||||
|
is already used, or the user account is disabled
|
||||||
|
"""
|
||||||
|
|
||||||
|
try:
|
||||||
|
sub_validator(sub)
|
||||||
|
except ValidationError as err:
|
||||||
|
raise SuspiciousOperation("User info contained an invalid sub claim") from err
|
||||||
|
|
||||||
|
try:
|
||||||
|
user = User.objects.get(sub=sub)
|
||||||
|
except User.DoesNotExist:
|
||||||
|
if not settings.OIDC_CREATE_USER:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Users are never identified by email here. But a second account with the
|
||||||
|
# same email (e.g. provisionnal account) would break the lookups by email:
|
||||||
|
# refuse it.
|
||||||
|
if email and User.objects.filter(email__iexact=email).exists():
|
||||||
|
logger.warning("Email already used, not creating user: %s", sub)
|
||||||
|
raise SuspiciousOperation(
|
||||||
|
"Email already used by another account."
|
||||||
|
) from None
|
||||||
|
|
||||||
|
user = User(sub=sub, email=email)
|
||||||
|
user.set_unusable_password()
|
||||||
|
user.save()
|
||||||
|
|
||||||
|
if not user.is_active:
|
||||||
|
logger.warning("Inactive user attempted authentication: %s", user.pk)
|
||||||
|
raise SuspiciousOperation("User account is disabled.")
|
||||||
|
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
class BaseJWTAuthentication(authentication.BaseAuthentication):
|
class BaseJWTAuthentication(authentication.BaseAuthentication):
|
||||||
"""Base JWT authentication class."""
|
"""Base JWT authentication class."""
|
||||||
|
|
||||||
@@ -72,25 +158,12 @@ class BaseJWTAuthentication(authentication.BaseAuthentication):
|
|||||||
if not self.is_enabled:
|
if not self.is_enabled:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
auth_header = authentication.get_authorization_header(request).split()
|
token = get_token(request, self._token_type)
|
||||||
|
|
||||||
if (
|
if token is None:
|
||||||
not auth_header
|
|
||||||
or auth_header[0].lower() != self._token_type.lower().encode()
|
|
||||||
):
|
|
||||||
# Defer to next authentication backend
|
# Defer to next authentication backend
|
||||||
return None
|
return None
|
||||||
|
|
||||||
if len(auth_header) != 2:
|
|
||||||
logger.warning("Invalid token header format")
|
|
||||||
raise exceptions.AuthenticationFailed("Invalid token header.")
|
|
||||||
|
|
||||||
try:
|
|
||||||
token = auth_header[1].decode("utf-8")
|
|
||||||
except UnicodeError as e:
|
|
||||||
logger.warning("Token decode error: %s", e)
|
|
||||||
raise exceptions.AuthenticationFailed("Invalid token encoding.") from e
|
|
||||||
|
|
||||||
return self.authenticate_credentials(token)
|
return self.authenticate_credentials(token)
|
||||||
|
|
||||||
def decode_jwt(self, token):
|
def decode_jwt(self, token):
|
||||||
@@ -256,6 +329,127 @@ class AddonsJWTAuthentication(BaseJWTAuthentication):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# Menshen grants scopes following La Suite's "service:resource:action" convention.
|
||||||
|
# Map them to the scopes expected by the external API permissions.
|
||||||
|
MENSHEN_SCOPES_MAPPING = {
|
||||||
|
"meet:room:create": ApplicationScope.ROOMS_CREATE,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache(1)
|
||||||
|
def _get_token_exchange_client(client_id, client_secret, server_url):
|
||||||
|
return TokenExchangeClient(
|
||||||
|
config=Configuration(
|
||||||
|
client_id,
|
||||||
|
client_secret,
|
||||||
|
server_url,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class MenshenAuthentication(authentication.BaseAuthentication):
|
||||||
|
"""Authentication for tokens exchanged through Menshen.
|
||||||
|
|
||||||
|
Menshen is La Suite's OAuth 2.0 token exchange server (RFC 8693): another service
|
||||||
|
exchanges its user's access token for a token targeting Meet, then calls the external
|
||||||
|
API on behalf of that user. Tokens are validated by introspection (RFC 7662). Menshen
|
||||||
|
only reports a token as active when Meet is among its audiences.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
"""Initialize the Menshen client from Django settings."""
|
||||||
|
|
||||||
|
super().__init__()
|
||||||
|
|
||||||
|
self._client = None
|
||||||
|
|
||||||
|
if not settings.MENSHEN_ENABLED:
|
||||||
|
return
|
||||||
|
|
||||||
|
self._client = _get_token_exchange_client(
|
||||||
|
settings.MENSHEN_CLIENT_ID,
|
||||||
|
settings.MENSHEN_CLIENT_SECRET,
|
||||||
|
settings.MENSHEN_SERVER_URL,
|
||||||
|
)
|
||||||
|
|
||||||
|
def authenticate(self, request):
|
||||||
|
"""Introspect the bearer token with Menshen.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (user, payload) if the token is active, None otherwise
|
||||||
|
"""
|
||||||
|
|
||||||
|
if self._client is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
token = get_token(request, "bearer")
|
||||||
|
|
||||||
|
if token is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = self._client.introspect(IntrospectionRequest(token=token))
|
||||||
|
except (requests.RequestException, ResponseParsingError) as e:
|
||||||
|
logger.warning("Menshen introspection failed: %s", e)
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Permission classes expect a dict payload in request.auth
|
||||||
|
payload = asdict(response)
|
||||||
|
|
||||||
|
if not payload.get("active"):
|
||||||
|
# Not a Menshen token, or an expired or revoked one: defer to next
|
||||||
|
# authentication backend
|
||||||
|
return None
|
||||||
|
|
||||||
|
user = self.get_or_create_user(payload)
|
||||||
|
|
||||||
|
scopes = payload.get("scope") or ""
|
||||||
|
payload["scope"] = [
|
||||||
|
MENSHEN_SCOPES_MAPPING[scope]
|
||||||
|
for scope in scopes.split()
|
||||||
|
if scope in MENSHEN_SCOPES_MAPPING
|
||||||
|
]
|
||||||
|
|
||||||
|
return (user, payload)
|
||||||
|
|
||||||
|
def get_or_create_user(self, payload):
|
||||||
|
"""Retrieve or create the user from the introspection response.
|
||||||
|
|
||||||
|
Menshen forwards the `sub` and `email` of the subject token, as introspected
|
||||||
|
with the OIDC provider.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
payload: Introspection response dict
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
User instance
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
AuthenticationFailed: If user not found or inactive
|
||||||
|
"""
|
||||||
|
|
||||||
|
sub = payload.get("sub")
|
||||||
|
|
||||||
|
if not sub:
|
||||||
|
logger.warning("Missing 'sub' in Menshen introspection response")
|
||||||
|
raise exceptions.AuthenticationFailed("Invalid token claims.")
|
||||||
|
|
||||||
|
try:
|
||||||
|
user = get_or_create_user_by_sub(sub, email=payload.get("email"))
|
||||||
|
except SuspiciousOperation as e:
|
||||||
|
raise exceptions.AuthenticationFailed(str(e)) from e
|
||||||
|
|
||||||
|
if user is None:
|
||||||
|
logger.warning("User not found: %s", sub)
|
||||||
|
raise exceptions.AuthenticationFailed("User not found.")
|
||||||
|
|
||||||
|
return user
|
||||||
|
|
||||||
|
def authenticate_header(self, request):
|
||||||
|
"""Return authentication scheme for WWW-Authenticate header."""
|
||||||
|
return "bearer"
|
||||||
|
|
||||||
|
|
||||||
class ResourceServerBackend(LaSuiteBackend):
|
class ResourceServerBackend(LaSuiteBackend):
|
||||||
"""OIDC Resource Server backend for user creation and retrieval."""
|
"""OIDC Resource Server backend for user creation and retrieval."""
|
||||||
|
|
||||||
@@ -285,28 +479,4 @@ class ResourceServerBackend(LaSuiteBackend):
|
|||||||
logger.debug(message)
|
logger.debug(message)
|
||||||
raise SuspiciousOperation(message)
|
raise SuspiciousOperation(message)
|
||||||
|
|
||||||
user = self.get_user(access_token, id_token, payload)
|
return get_or_create_user_by_sub(sub)
|
||||||
|
|
||||||
if user is None and settings.OIDC_CREATE_USER:
|
|
||||||
user = self.create_user(sub)
|
|
||||||
|
|
||||||
if user is not None and not user.is_active:
|
|
||||||
logger.warning("Inactive user attempted authentication: %s", user.pk)
|
|
||||||
raise SuspiciousOperation("User account is disabled.")
|
|
||||||
|
|
||||||
return user
|
|
||||||
|
|
||||||
def create_user(self, sub):
|
|
||||||
"""Create new user from subject claim.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
sub: Subject identifier from token
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Newly created User instance
|
|
||||||
"""
|
|
||||||
user = self.UserModel(sub=sub)
|
|
||||||
user.set_unusable_password()
|
|
||||||
user.save()
|
|
||||||
|
|
||||||
return user
|
|
||||||
|
|||||||
@@ -166,6 +166,7 @@ class RoomViewSet(
|
|||||||
authentication_classes = [
|
authentication_classes = [
|
||||||
authentication.ApplicationJWTAuthentication,
|
authentication.ApplicationJWTAuthentication,
|
||||||
authentication.AddonsJWTAuthentication,
|
authentication.AddonsJWTAuthentication,
|
||||||
|
authentication.MenshenAuthentication,
|
||||||
ResourceServerAuthentication,
|
ResourceServerAuthentication,
|
||||||
]
|
]
|
||||||
permission_classes = [
|
permission_classes = [
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
# Generated by Django 5.2.14 on 2026-10-07 18:45
|
||||||
|
|
||||||
|
from django.contrib.postgres.operations import AddIndexConcurrently
|
||||||
|
from django.db import migrations, models
|
||||||
|
from django.db.models.functions import Upper
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
atomic = False
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('core', '0026_alter_application_scopes'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
AddIndexConcurrently(
|
||||||
|
model_name="user",
|
||||||
|
index=models.Index(
|
||||||
|
Upper("email"),
|
||||||
|
name="user_email_upper_idx",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -246,6 +246,9 @@ class User(AbstractBaseUser, BaseModel, auth_models.PermissionsMixin):
|
|||||||
name="unique_email_when_sub_is_null",
|
name="unique_email_when_sub_is_null",
|
||||||
)
|
)
|
||||||
]
|
]
|
||||||
|
indexes = [
|
||||||
|
models.Index(models.functions.Upper("email"), name="user_email_upper_idx"),
|
||||||
|
]
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return self.email or self.admin_email or str(self.id)
|
return self.email or self.admin_email or str(self.id)
|
||||||
|
|||||||
@@ -7,18 +7,18 @@ from datetime import datetime, timezone
|
|||||||
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.core.mail import send_mail
|
from django.utils.translation import get_language, gettext, gettext_noop, override
|
||||||
from django.template.loader import render_to_string
|
|
||||||
from django.utils.translation import get_language, gettext, override
|
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
import aiohttp
|
import aiohttp
|
||||||
import requests
|
import requests
|
||||||
from asgiref.sync import async_to_sync
|
from asgiref.sync import async_to_sync
|
||||||
|
from celery.exceptions import OperationalError as CeleryOperationalError
|
||||||
from livekit import api as livekit_api
|
from livekit import api as livekit_api
|
||||||
|
|
||||||
from core import models, utils
|
from core import models, utils
|
||||||
from core.analytics import UserFeatureFlag, is_user_feature_flag_enabled
|
from core.analytics import UserFeatureFlag, is_user_feature_flag_enabled
|
||||||
|
from core.tasks.mail import send_email
|
||||||
from core.utils import generate_download_s3_url
|
from core.utils import generate_download_s3_url
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -68,10 +68,14 @@ class NotificationService:
|
|||||||
@staticmethod
|
@staticmethod
|
||||||
def _notify_user_by_email(recording) -> bool:
|
def _notify_user_by_email(recording) -> bool:
|
||||||
"""
|
"""
|
||||||
Send an email notification to recording owners when their recording is ready.
|
Queue an email notification to recording owners when their recording is ready.
|
||||||
|
|
||||||
The email includes a direct link that redirects owners to a dedicated download
|
The email includes a direct link that redirects owners to a dedicated download
|
||||||
page in the frontend where they can access their specific recording.
|
page in the frontend where they can access their specific recording.
|
||||||
|
|
||||||
|
Rendering and SMTP delivery happen in a Celery worker (``send_email``), so
|
||||||
|
this only does one DB query and enqueues one message per owner.
|
||||||
|
Returns True when every email was queued (or sent, if Celery is disabled).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
owner_accesses = (
|
owner_accesses = (
|
||||||
@@ -88,10 +92,6 @@ class NotificationService:
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
context = {
|
context = {
|
||||||
"brandname": settings.EMAIL_BRAND_NAME,
|
|
||||||
"support_email": settings.EMAIL_SUPPORT_EMAIL,
|
|
||||||
"logo_img": settings.EMAIL_LOGO_IMG,
|
|
||||||
"domain": settings.EMAIL_DOMAIN,
|
|
||||||
"room_name": recording.room.name,
|
"room_name": recording.room.name,
|
||||||
"recording_expiration_days": settings.RECORDING_EXPIRATION_DAYS,
|
"recording_expiration_days": settings.RECORDING_EXPIRATION_DAYS,
|
||||||
"link": f"{get_recording_download_base_url()}/{recording.id}",
|
"link": f"{get_recording_download_base_url()}/{recording.id}",
|
||||||
@@ -104,37 +104,22 @@ class NotificationService:
|
|||||||
# 2. The number of recipients per recording is typically small (not thousands)
|
# 2. The number of recipients per recording is typically small (not thousands)
|
||||||
for access in owner_accesses:
|
for access in owner_accesses:
|
||||||
user = access.user
|
user = access.user
|
||||||
language = user.language or get_language()
|
local_created_at = recording.created_at.astimezone(user.timezone)
|
||||||
with override(language):
|
try:
|
||||||
personalized_context = {
|
send_email.delay(
|
||||||
"recording_date": recording.created_at.astimezone(
|
template="screen_recording",
|
||||||
user.timezone
|
subject=gettext_noop("Your recording is ready"),
|
||||||
).strftime("%Y-%m-%d"),
|
recipients=[user.email],
|
||||||
"recording_time": recording.created_at.astimezone(
|
language=user.language or get_language(),
|
||||||
user.timezone
|
context={
|
||||||
).strftime("%H:%M"),
|
**context,
|
||||||
**context,
|
"recording_date": local_created_at.strftime("%Y-%m-%d"),
|
||||||
}
|
"recording_time": local_created_at.strftime("%H:%M"),
|
||||||
msg_html = render_to_string(
|
},
|
||||||
"mail/html/screen_recording.html", personalized_context
|
|
||||||
)
|
)
|
||||||
msg_plain = render_to_string(
|
except (smtplib.SMTPException, CeleryOperationalError) as exception:
|
||||||
"mail/text/screen_recording.txt", personalized_context
|
logger.error("notification could not be sent: %s", exception)
|
||||||
)
|
has_failures = True
|
||||||
subject = gettext("Your recording is ready") # Force translation
|
|
||||||
|
|
||||||
try:
|
|
||||||
send_mail(
|
|
||||||
subject.capitalize(),
|
|
||||||
msg_plain,
|
|
||||||
settings.EMAIL_FROM,
|
|
||||||
[user.email],
|
|
||||||
html_message=msg_html,
|
|
||||||
fail_silently=False,
|
|
||||||
)
|
|
||||||
except smtplib.SMTPException as exception:
|
|
||||||
logger.error("notification could not be sent: %s", exception)
|
|
||||||
has_failures = True
|
|
||||||
|
|
||||||
return not has_failures
|
return not has_failures
|
||||||
|
|
||||||
|
|||||||
@@ -2,8 +2,10 @@
|
|||||||
|
|
||||||
from core.tasks.connection_test import delete_connection_test_room
|
from core.tasks.connection_test import delete_connection_test_room
|
||||||
from core.tasks.file import process_file_deletion
|
from core.tasks.file import process_file_deletion
|
||||||
|
from core.tasks.mail import send_email
|
||||||
|
|
||||||
__all__ = (
|
__all__ = (
|
||||||
"delete_connection_test_room",
|
"delete_connection_test_room",
|
||||||
"process_file_deletion",
|
"process_file_deletion",
|
||||||
|
"send_email",
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
"""Send mail using celery task."""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import smtplib
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
from django.core.mail import send_mail
|
||||||
|
from django.template.loader import render_to_string
|
||||||
|
from django.utils.translation import get_language, gettext, override
|
||||||
|
|
||||||
|
from core.tasks._task import task
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def get_base_email_context() -> dict:
|
||||||
|
"""Context shared by every email template (branding, support, domain)."""
|
||||||
|
return {
|
||||||
|
"brandname": settings.EMAIL_BRAND_NAME,
|
||||||
|
"support_email": settings.EMAIL_SUPPORT_EMAIL,
|
||||||
|
"logo_img": settings.EMAIL_LOGO_IMG,
|
||||||
|
"domain": settings.EMAIL_DOMAIN,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@task(
|
||||||
|
autoretry_for=(smtplib.SMTPException, ConnectionError, TimeoutError),
|
||||||
|
retry_backoff=True,
|
||||||
|
retry_backoff_max=600,
|
||||||
|
retry_jitter=True,
|
||||||
|
max_retries=5,
|
||||||
|
)
|
||||||
|
def send_email(
|
||||||
|
*,
|
||||||
|
template: str,
|
||||||
|
subject: str,
|
||||||
|
recipients: list[str],
|
||||||
|
context: dict | None = None,
|
||||||
|
language: str | None = None,
|
||||||
|
):
|
||||||
|
"""Render a mail template and send it.
|
||||||
|
|
||||||
|
Generic on purpose: any feature can reuse it with its own template and context.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
template: Template base name, e.g. "screen_recording". Both
|
||||||
|
"mail/html/<template>.html" and "mail/text/<template>.txt" must exist.
|
||||||
|
subject: Untranslated subject msgid. Mark it with ``gettext_noop`` at the
|
||||||
|
call site; it is translated here, in ``language``.
|
||||||
|
recipients: Email addresses, all receiving the same rendered message.
|
||||||
|
context: Template context. Must be JSON-serializable (str, int, list, dict…):
|
||||||
|
pre-format dates, cast UUIDs to str. Merged over the base branding context.
|
||||||
|
language: Language to render the email in. Defaults to the active language.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
OSError: SMTP or network failure, after retries are exhausted (or
|
||||||
|
immediately when Celery is disabled and the call runs synchronously).
|
||||||
|
"""
|
||||||
|
full_context = {**get_base_email_context(), **(context or {})}
|
||||||
|
|
||||||
|
with override(language or get_language()):
|
||||||
|
msg_html = render_to_string(f"mail/html/{template}.html", full_context)
|
||||||
|
msg_plain = render_to_string(f"mail/text/{template}.txt", full_context)
|
||||||
|
translated_subject = gettext(subject)
|
||||||
|
|
||||||
|
send_mail(
|
||||||
|
translated_subject,
|
||||||
|
msg_plain,
|
||||||
|
settings.EMAIL_FROM,
|
||||||
|
recipients,
|
||||||
|
html_message=msg_html,
|
||||||
|
fail_silently=False,
|
||||||
|
)
|
||||||
|
logger.info("Email '%s' sent to %d recipient(s)", template, len(recipients))
|
||||||
@@ -12,6 +12,7 @@ from unittest import mock
|
|||||||
from django.contrib.sites.models import Site
|
from django.contrib.sites.models import Site
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
from celery.exceptions import OperationalError as CeleryOperationalError
|
||||||
|
|
||||||
from core import factories, models
|
from core import factories, models
|
||||||
from core.analytics import UserFeatureFlag
|
from core.analytics import UserFeatureFlag
|
||||||
@@ -142,7 +143,7 @@ def test_notify_user_by_email_success(mocked_current_site, settings):
|
|||||||
|
|
||||||
notification_service = NotificationService()
|
notification_service = NotificationService()
|
||||||
|
|
||||||
with mock.patch("core.recording.event.notification.send_mail") as mock_send_mail:
|
with mock.patch("core.tasks.mail.send_mail") as mock_send_mail:
|
||||||
result = notification_service._notify_user_by_email(recording)
|
result = notification_service._notify_user_by_email(recording)
|
||||||
|
|
||||||
assert result is True
|
assert result is True
|
||||||
@@ -237,7 +238,7 @@ def test_notify_user_by_email_smtp_exception(mocked_current_site, caplog):
|
|||||||
notification_service = NotificationService()
|
notification_service = NotificationService()
|
||||||
|
|
||||||
with mock.patch(
|
with mock.patch(
|
||||||
"core.recording.event.notification.send_mail",
|
"core.tasks.mail.send_mail",
|
||||||
side_effect=smtplib.SMTPException("SMTP Error"),
|
side_effect=smtplib.SMTPException("SMTP Error"),
|
||||||
) as mock_send_mail:
|
) as mock_send_mail:
|
||||||
result = notification_service._notify_user_by_email(recording)
|
result = notification_service._notify_user_by_email(recording)
|
||||||
@@ -247,6 +248,60 @@ def test_notify_user_by_email_smtp_exception(mocked_current_site, caplog):
|
|||||||
assert "notification could not be sent:" in caplog.text
|
assert "notification could not be sent:" in caplog.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_notify_user_by_email_queues_generic_task(settings):
|
||||||
|
"""One generic send_email task is queued per owner, with serializable args."""
|
||||||
|
settings.RECORDING_DOWNLOAD_BASE_URL = "https://acme.com/recordings"
|
||||||
|
settings.RECORDING_EXPIRATION_DAYS = 7
|
||||||
|
|
||||||
|
recording = factories.RecordingFactory(room__name="Conference Room A")
|
||||||
|
recording.created_at = datetime.datetime(
|
||||||
|
2023, 5, 15, 14, 30, 0, tzinfo=datetime.timezone.utc
|
||||||
|
)
|
||||||
|
user = factories.UserFactory(
|
||||||
|
email="franc@test.com", language="fr-fr", timezone="Europe/Paris"
|
||||||
|
)
|
||||||
|
factories.UserRecordingAccessFactory(
|
||||||
|
recording=recording, role=models.RoleChoices.OWNER, user=user
|
||||||
|
)
|
||||||
|
|
||||||
|
with mock.patch("core.recording.event.notification.send_email.delay") as mock_delay:
|
||||||
|
result = notification_service._notify_user_by_email(recording)
|
||||||
|
|
||||||
|
assert result is True
|
||||||
|
mock_delay.assert_called_once_with(
|
||||||
|
template="screen_recording",
|
||||||
|
subject="Your recording is ready",
|
||||||
|
recipients=["franc@test.com"],
|
||||||
|
language="fr-fr",
|
||||||
|
context={
|
||||||
|
"room_name": "Conference Room A",
|
||||||
|
"recording_expiration_days": 7,
|
||||||
|
"link": f"https://acme.com/recordings/{recording.id}",
|
||||||
|
"recording_date": "2023-05-15",
|
||||||
|
"recording_time": "16:30",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
# Celery's JSON serializer must accept every argument
|
||||||
|
json.dumps(mock_delay.call_args.kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
def test_notify_user_by_email_broker_unavailable(caplog):
|
||||||
|
"""A broker outage is reported as a failure without crashing the caller."""
|
||||||
|
recording = factories.RecordingFactory()
|
||||||
|
factories.UserRecordingAccessFactory(
|
||||||
|
recording=recording, role=models.RoleChoices.OWNER
|
||||||
|
)
|
||||||
|
|
||||||
|
with mock.patch(
|
||||||
|
"core.recording.event.notification.send_email.delay",
|
||||||
|
side_effect=CeleryOperationalError("broker down"),
|
||||||
|
):
|
||||||
|
result = notification_service._notify_user_by_email(recording)
|
||||||
|
|
||||||
|
assert result is False
|
||||||
|
assert "notification could not be sent: broker down" in caplog.text
|
||||||
|
|
||||||
|
|
||||||
@mock.patch("core.recording.event.notification.requests.post")
|
@mock.patch("core.recording.event.notification.requests.post")
|
||||||
@mock.patch("core.recording.event.notification.generate_download_s3_url")
|
@mock.patch("core.recording.event.notification.generate_download_s3_url")
|
||||||
@mock.patch.object(
|
@mock.patch.object(
|
||||||
|
|||||||
@@ -0,0 +1,98 @@
|
|||||||
|
"""Tests for the generic send_email task."""
|
||||||
|
|
||||||
|
# pylint: disable=unused-argument,redefined-outer-name
|
||||||
|
|
||||||
|
import smtplib
|
||||||
|
from unittest import mock
|
||||||
|
|
||||||
|
from django.core.mail import get_connection
|
||||||
|
from django.utils.translation import gettext_noop
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from core.tasks.mail import send_email
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def email_settings(settings):
|
||||||
|
"""Branding settings shared by every email."""
|
||||||
|
settings.EMAIL_BRAND_NAME = "ACME"
|
||||||
|
settings.EMAIL_SUPPORT_EMAIL = "support@acme.com"
|
||||||
|
settings.EMAIL_LOGO_IMG = "https://acme.com/logo"
|
||||||
|
settings.EMAIL_DOMAIN = "acme.com"
|
||||||
|
settings.EMAIL_FROM = "notifications@acme.com"
|
||||||
|
return settings
|
||||||
|
|
||||||
|
|
||||||
|
@mock.patch("core.tasks.mail.send_mail")
|
||||||
|
@mock.patch("core.tasks.mail.render_to_string", side_effect=["<p>html</p>", "text"])
|
||||||
|
def test_send_email_renders_template_and_sends(
|
||||||
|
mock_render, mock_send_mail, email_settings
|
||||||
|
):
|
||||||
|
"""Both template variants are rendered with base + caller context, then sent."""
|
||||||
|
send_email(
|
||||||
|
template="some_template",
|
||||||
|
subject="Any subject",
|
||||||
|
recipients=["a@test.com", "b@test.com"],
|
||||||
|
context={"foo": "bar", "brandname": "Overridden"},
|
||||||
|
)
|
||||||
|
|
||||||
|
expected_context = {
|
||||||
|
"brandname": "Overridden",
|
||||||
|
"support_email": "support@acme.com",
|
||||||
|
"logo_img": "https://acme.com/logo",
|
||||||
|
"domain": "acme.com",
|
||||||
|
"foo": "bar",
|
||||||
|
}
|
||||||
|
assert mock_render.call_args_list == [
|
||||||
|
mock.call("mail/html/some_template.html", expected_context),
|
||||||
|
mock.call("mail/text/some_template.txt", expected_context),
|
||||||
|
]
|
||||||
|
mock_send_mail.assert_called_once_with(
|
||||||
|
"Any subject",
|
||||||
|
"text",
|
||||||
|
"notifications@acme.com",
|
||||||
|
["a@test.com", "b@test.com"],
|
||||||
|
html_message="<p>html</p>",
|
||||||
|
fail_silently=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@mock.patch("core.tasks.mail.send_mail")
|
||||||
|
def test_send_email_translates_subject_in_requested_language(
|
||||||
|
mock_send_mail, email_settings
|
||||||
|
):
|
||||||
|
"""The subject msgid is translated in the worker, in the recipient language."""
|
||||||
|
send_email(
|
||||||
|
template="screen_recording",
|
||||||
|
subject=gettext_noop("Your recording is ready"),
|
||||||
|
recipients=["franc@test.com"],
|
||||||
|
context={"room_name": "Room", "link": "https://acme.com/r/1"},
|
||||||
|
language="fr-fr",
|
||||||
|
)
|
||||||
|
|
||||||
|
subject, body, *_ = mock_send_mail.call_args[0]
|
||||||
|
assert subject == "Votre enregistrement est prêt"
|
||||||
|
assert "Votre enregistrement est prêt !" in body
|
||||||
|
|
||||||
|
|
||||||
|
@mock.patch(
|
||||||
|
"core.tasks.mail.send_mail", side_effect=smtplib.SMTPException("SMTP Error")
|
||||||
|
)
|
||||||
|
@mock.patch("core.tasks.mail.render_to_string", return_value="content")
|
||||||
|
def test_send_email_raises_on_smtp_error(mock_render, mock_send_mail, email_settings):
|
||||||
|
"""SMTP errors propagate so Celery can retry the task."""
|
||||||
|
with pytest.raises(smtplib.SMTPException):
|
||||||
|
send_email(
|
||||||
|
template="some_template",
|
||||||
|
subject="Any subject",
|
||||||
|
recipients=["a@test.com"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_smtp_backend_uses_email_timeout(settings):
|
||||||
|
"""A stalled SMTP server cannot hold the worker forever."""
|
||||||
|
settings.EMAIL_BACKEND = "django.core.mail.backends.smtp.EmailBackend"
|
||||||
|
settings.EMAIL_TIMEOUT = 12
|
||||||
|
|
||||||
|
assert get_connection().timeout == 12
|
||||||
@@ -35,6 +35,20 @@ def test_user_access_token_users_me():
|
|||||||
assert response.data["email"] == user.email
|
assert response.data["email"] == user.email
|
||||||
|
|
||||||
|
|
||||||
|
def test_user_access_token_custom_token_type(settings):
|
||||||
|
"""The Authorization scheme should match the configured token type."""
|
||||||
|
settings.USER_ACCESS_TOKEN_TYPE = "Token"
|
||||||
|
user = UserFactory()
|
||||||
|
|
||||||
|
client = APIClient()
|
||||||
|
client.credentials(HTTP_AUTHORIZATION=f"Token {generate_user_access_token(user)}")
|
||||||
|
|
||||||
|
response = client.get("/api/v1.0/users/me/")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.data["email"] == user.email
|
||||||
|
|
||||||
|
|
||||||
def test_user_access_token_expired():
|
def test_user_access_token_expired():
|
||||||
"""An expired user access token should be rejected."""
|
"""An expired user access token should be rejected."""
|
||||||
user = UserFactory()
|
user = UserFactory()
|
||||||
|
|||||||
@@ -67,6 +67,19 @@ def test_resource_server_backend_get_or_create_user_no_creation(settings):
|
|||||||
assert not User.objects.filter(sub="new-sub").exists()
|
assert not User.objects.filter(sub="new-sub").exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_resource_server_backend_get_or_create_user_invalid_sub(settings):
|
||||||
|
"""A sub longer than 255 characters should be rejected without creating a user."""
|
||||||
|
|
||||||
|
settings.OIDC_CREATE_USER = True
|
||||||
|
|
||||||
|
with pytest.raises(SuspiciousOperation, match="invalid sub claim"):
|
||||||
|
ResourceServerBackend().get_or_create_user(
|
||||||
|
access_token="token", id_token=None, payload=_payload("a" * 256)
|
||||||
|
)
|
||||||
|
|
||||||
|
assert not User.objects.exists()
|
||||||
|
|
||||||
|
|
||||||
@responses.activate
|
@responses.activate
|
||||||
def test_api_rooms_list_resource_server_inactive_user(settings):
|
def test_api_rooms_list_resource_server_inactive_user(settings):
|
||||||
"""End to end: a valid introspected token for an inactive user should get 401."""
|
"""End to end: a valid introspected token for an inactive user should get 401."""
|
||||||
|
|||||||
@@ -0,0 +1,302 @@
|
|||||||
|
"""Tests for the external API MenshenAuthentication."""
|
||||||
|
|
||||||
|
from unittest import mock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
import responses
|
||||||
|
from lasuite.oidc_resource_server.authentication import ResourceServerAuthentication
|
||||||
|
from rest_framework.test import APIClient
|
||||||
|
|
||||||
|
from core.factories import UserFactory
|
||||||
|
from core.models import RoleChoices, Room, User
|
||||||
|
|
||||||
|
pytestmark = pytest.mark.django_db
|
||||||
|
|
||||||
|
SERVER_URL = "https://menshen.example.com"
|
||||||
|
INTROSPECTION_ENDPOINT = f"{SERVER_URL}/auth/token/introspect/"
|
||||||
|
TOKEN = "menshen-exchanged-token"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def menshen_settings(settings):
|
||||||
|
"""Enable Menshen authentication."""
|
||||||
|
settings.MENSHEN_ENABLED = True
|
||||||
|
settings.MENSHEN_SERVER_URL = SERVER_URL
|
||||||
|
settings.MENSHEN_CLIENT_ID = "meet"
|
||||||
|
settings.MENSHEN_CLIENT_SECRET = "meet-secret"
|
||||||
|
|
||||||
|
|
||||||
|
def _introspection(sub, scope="meet:room:create", **overrides):
|
||||||
|
return {
|
||||||
|
"active": True,
|
||||||
|
"sub": sub,
|
||||||
|
"email": "user@example.com",
|
||||||
|
"scope": scope,
|
||||||
|
"aud": "meet",
|
||||||
|
"client_id": "menshen",
|
||||||
|
**overrides,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _create_room(token=TOKEN):
|
||||||
|
client = APIClient()
|
||||||
|
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
||||||
|
return client.post("/external-api/v1.0/rooms/", {}, format="json")
|
||||||
|
|
||||||
|
|
||||||
|
@responses.activate
|
||||||
|
def test_menshen_active_token():
|
||||||
|
"""An active token with a mapped scope should create a room owned by the user."""
|
||||||
|
|
||||||
|
user = UserFactory()
|
||||||
|
|
||||||
|
# Catch and mock external HTTP requests
|
||||||
|
responses.add(
|
||||||
|
responses.POST,
|
||||||
|
INTROSPECTION_ENDPOINT,
|
||||||
|
json=_introspection(user.sub),
|
||||||
|
match=[responses.matchers.urlencoded_params_matcher({"token": TOKEN})],
|
||||||
|
)
|
||||||
|
|
||||||
|
response = _create_room()
|
||||||
|
|
||||||
|
assert response.status_code == 201
|
||||||
|
room = Room.objects.get(id=response.data["id"])
|
||||||
|
assert room.get_role(user) == RoleChoices.OWNER
|
||||||
|
assert responses.calls[0].request.headers["Authorization"].startswith("Basic ")
|
||||||
|
|
||||||
|
|
||||||
|
@responses.activate
|
||||||
|
@mock.patch.object(ResourceServerAuthentication, "authenticate", return_value=None)
|
||||||
|
def test_menshen_other_token_type_ignored(mock_rs_authenticate):
|
||||||
|
"""A token sent with another scheme than Bearer should not be introspected."""
|
||||||
|
|
||||||
|
client = APIClient()
|
||||||
|
client.credentials(HTTP_AUTHORIZATION=f"Token {TOKEN}")
|
||||||
|
response = client.post("/external-api/v1.0/rooms/", {}, format="json")
|
||||||
|
|
||||||
|
assert response.status_code == 401
|
||||||
|
assert len(responses.calls) == 0
|
||||||
|
mock_rs_authenticate.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
|
@responses.activate
|
||||||
|
@mock.patch.object(ResourceServerAuthentication, "authenticate", return_value=None)
|
||||||
|
def test_menshen_disabled(mock_rs_authenticate, settings):
|
||||||
|
"""Menshen should not be called when disabled."""
|
||||||
|
|
||||||
|
settings.MENSHEN_ENABLED = False
|
||||||
|
|
||||||
|
response = _create_room()
|
||||||
|
|
||||||
|
assert response.status_code == 401
|
||||||
|
assert len(responses.calls) == 0
|
||||||
|
mock_rs_authenticate.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
|
@responses.activate
|
||||||
|
@mock.patch.object(ResourceServerAuthentication, "authenticate", return_value=None)
|
||||||
|
def test_menshen_inactive_token_defers(mock_rs_authenticate):
|
||||||
|
"""An inactive token should defer to the next authentication backend."""
|
||||||
|
|
||||||
|
user = UserFactory()
|
||||||
|
|
||||||
|
responses.add(
|
||||||
|
responses.POST,
|
||||||
|
INTROSPECTION_ENDPOINT,
|
||||||
|
json=_introspection(user.sub, scope="meet:room:create", active=False),
|
||||||
|
)
|
||||||
|
|
||||||
|
response = _create_room()
|
||||||
|
|
||||||
|
assert response.status_code == 401
|
||||||
|
mock_rs_authenticate.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
|
@responses.activate
|
||||||
|
@mock.patch.object(ResourceServerAuthentication, "authenticate", return_value=None)
|
||||||
|
def test_menshen_error_defers(mock_rs_authenticate):
|
||||||
|
"""A Menshen error should defer to the next authentication backend."""
|
||||||
|
|
||||||
|
responses.add(responses.POST, INTROSPECTION_ENDPOINT, status=500)
|
||||||
|
|
||||||
|
response = _create_room()
|
||||||
|
|
||||||
|
assert response.status_code == 401
|
||||||
|
mock_rs_authenticate.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
|
@responses.activate
|
||||||
|
@mock.patch.object(ResourceServerAuthentication, "authenticate", return_value=None)
|
||||||
|
def test_menshen_unparsable_response_defers(mock_rs_authenticate):
|
||||||
|
"""A response the client can't parse should defer to the next backend."""
|
||||||
|
|
||||||
|
responses.add(
|
||||||
|
responses.POST,
|
||||||
|
INTROSPECTION_ENDPOINT,
|
||||||
|
json={"active": True, "unexpected": "field"},
|
||||||
|
)
|
||||||
|
|
||||||
|
response = _create_room()
|
||||||
|
|
||||||
|
assert response.status_code == 401
|
||||||
|
mock_rs_authenticate.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
|
@responses.activate
|
||||||
|
def test_menshen_resource_server_token(settings):
|
||||||
|
"""End to end: a token issued by the OIDC provider is unknown to Menshen and
|
||||||
|
should still authenticate through the resource server."""
|
||||||
|
|
||||||
|
settings.OIDC_OP_INTROSPECTION_ENDPOINT = "https://oidc.example.com/introspect"
|
||||||
|
settings.OIDC_OP_URL = "https://oidc.example.com"
|
||||||
|
|
||||||
|
user = UserFactory()
|
||||||
|
token = "oidc-access-token"
|
||||||
|
|
||||||
|
menshen_introspection = responses.add(
|
||||||
|
responses.POST,
|
||||||
|
INTROSPECTION_ENDPOINT,
|
||||||
|
json={"active": False},
|
||||||
|
match=[responses.matchers.urlencoded_params_matcher({"token": token})],
|
||||||
|
)
|
||||||
|
oidc_introspection = responses.add(
|
||||||
|
responses.POST,
|
||||||
|
"https://oidc.example.com/introspect",
|
||||||
|
json={
|
||||||
|
"iss": "https://oidc.example.com",
|
||||||
|
"active": True,
|
||||||
|
"sub": user.sub,
|
||||||
|
"scope": "openid lasuite_meet rooms:create",
|
||||||
|
"client_id": "app",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
response = _create_room(token)
|
||||||
|
|
||||||
|
assert response.status_code == 201
|
||||||
|
room = Room.objects.get(id=response.data["id"])
|
||||||
|
assert room.get_role(user) == RoleChoices.OWNER
|
||||||
|
assert menshen_introspection.call_count == 1
|
||||||
|
assert oidc_introspection.call_count == 1
|
||||||
|
|
||||||
|
|
||||||
|
@responses.activate
|
||||||
|
def test_menshen_unmapped_scope():
|
||||||
|
"""Scopes granted for other services or other Meet actions should not grant access."""
|
||||||
|
|
||||||
|
user = UserFactory()
|
||||||
|
|
||||||
|
responses.add(
|
||||||
|
responses.POST,
|
||||||
|
INTROSPECTION_ENDPOINT,
|
||||||
|
json=_introspection(user.sub, scope="drive:item:create meet:room:list"),
|
||||||
|
)
|
||||||
|
|
||||||
|
response = _create_room()
|
||||||
|
|
||||||
|
assert response.status_code == 403
|
||||||
|
assert "insufficient permissions." in str(response.data).lower()
|
||||||
|
assert not Room.objects.exists()
|
||||||
|
|
||||||
|
|
||||||
|
@responses.activate
|
||||||
|
def test_menshen_missing_sub():
|
||||||
|
"""An active token without sub should be rejected."""
|
||||||
|
|
||||||
|
responses.add(responses.POST, INTROSPECTION_ENDPOINT, json=_introspection(None))
|
||||||
|
|
||||||
|
response = _create_room()
|
||||||
|
|
||||||
|
assert response.status_code == 401
|
||||||
|
assert "invalid token claims." in str(response.data).lower()
|
||||||
|
|
||||||
|
|
||||||
|
@responses.activate
|
||||||
|
def test_menshen_invalid_sub(settings):
|
||||||
|
"""An active token with an invalid sub should be rejected without creating a user."""
|
||||||
|
|
||||||
|
settings.OIDC_CREATE_USER = True
|
||||||
|
|
||||||
|
responses.add(
|
||||||
|
responses.POST, INTROSPECTION_ENDPOINT, json=_introspection("invalid\nsub")
|
||||||
|
)
|
||||||
|
|
||||||
|
response = _create_room()
|
||||||
|
|
||||||
|
assert response.status_code == 401
|
||||||
|
assert "invalid sub claim" in str(response.data).lower()
|
||||||
|
assert not User.objects.exists()
|
||||||
|
|
||||||
|
|
||||||
|
@responses.activate
|
||||||
|
def test_menshen_inactive_user():
|
||||||
|
"""An active token for an inactive user should be rejected."""
|
||||||
|
|
||||||
|
user = UserFactory(is_active=False)
|
||||||
|
|
||||||
|
responses.add(responses.POST, INTROSPECTION_ENDPOINT, json=_introspection(user.sub))
|
||||||
|
|
||||||
|
response = _create_room()
|
||||||
|
|
||||||
|
assert response.status_code == 401
|
||||||
|
assert "user account is disabled." in str(response.data).lower()
|
||||||
|
|
||||||
|
|
||||||
|
@responses.activate
|
||||||
|
def test_menshen_unknown_user_created(settings):
|
||||||
|
"""An unknown sub should create a user when OIDC_CREATE_USER is set."""
|
||||||
|
|
||||||
|
settings.OIDC_CREATE_USER = True
|
||||||
|
|
||||||
|
responses.add(
|
||||||
|
responses.POST, INTROSPECTION_ENDPOINT, json=_introspection("new-sub")
|
||||||
|
)
|
||||||
|
|
||||||
|
response = _create_room()
|
||||||
|
|
||||||
|
assert response.status_code == 201
|
||||||
|
user = User.objects.get(sub="new-sub")
|
||||||
|
assert user.email == "user@example.com"
|
||||||
|
assert user.is_active is True
|
||||||
|
|
||||||
|
|
||||||
|
@responses.activate
|
||||||
|
def test_menshen_unknown_user_not_created(settings):
|
||||||
|
"""An unknown sub should be rejected when OIDC_CREATE_USER is unset."""
|
||||||
|
|
||||||
|
settings.OIDC_CREATE_USER = False
|
||||||
|
|
||||||
|
responses.add(
|
||||||
|
responses.POST, INTROSPECTION_ENDPOINT, json=_introspection("new-sub")
|
||||||
|
)
|
||||||
|
|
||||||
|
response = _create_room()
|
||||||
|
|
||||||
|
assert response.status_code == 401
|
||||||
|
assert "user not found." in str(response.data).lower()
|
||||||
|
assert not User.objects.filter(sub="new-sub").exists()
|
||||||
|
|
||||||
|
|
||||||
|
@responses.activate
|
||||||
|
@pytest.mark.parametrize("existing_sub", [None, "other-sub"])
|
||||||
|
def test_menshen_unknown_user_email_already_used(settings, existing_sub):
|
||||||
|
"""An unknown sub should not create a second account with an email already
|
||||||
|
used, by a provisional user or a user with another sub."""
|
||||||
|
|
||||||
|
settings.OIDC_CREATE_USER = True
|
||||||
|
|
||||||
|
existing_user = UserFactory(sub=existing_sub, email="User@Example.com")
|
||||||
|
|
||||||
|
responses.add(
|
||||||
|
responses.POST, INTROSPECTION_ENDPOINT, json=_introspection("new-sub")
|
||||||
|
)
|
||||||
|
|
||||||
|
response = _create_room()
|
||||||
|
|
||||||
|
assert response.status_code == 401
|
||||||
|
assert "email already used by another account." in str(response.data).lower()
|
||||||
|
assert User.objects.count() == 1
|
||||||
|
existing_user.refresh_from_db()
|
||||||
|
assert existing_user.sub == existing_sub
|
||||||
|
assert not Room.objects.exists()
|
||||||
@@ -69,7 +69,7 @@ def test_models_users_sub_validator(sub, is_valid):
|
|||||||
else:
|
else:
|
||||||
with pytest.raises(
|
with pytest.raises(
|
||||||
ValidationError,
|
ValidationError,
|
||||||
match="Enter a valid sub. This value should be printable ASCII only.",
|
match="Enter a valid sub. This value should be printable ASCII only, with 255 characters or less.",
|
||||||
):
|
):
|
||||||
user.full_clean()
|
user.full_clean()
|
||||||
|
|
||||||
|
|||||||
@@ -17,7 +17,13 @@ def sub_validator(value):
|
|||||||
``isprintable()`` is false exactly for those control characters, while
|
``isprintable()`` is false exactly for those control characters, while
|
||||||
space (U+0020) remains allowed.
|
space (U+0020) remains allowed.
|
||||||
"""
|
"""
|
||||||
|
msg = "Enter a valid sub. This value should be printable ASCII only, with 255 characters or less."
|
||||||
|
|
||||||
|
if value is None:
|
||||||
|
raise ValidationError(_(msg))
|
||||||
|
|
||||||
|
if len(value) > 255:
|
||||||
|
raise ValidationError(_(msg))
|
||||||
|
|
||||||
if not value.isascii() or not value.isprintable():
|
if not value.isascii() or not value.isprintable():
|
||||||
raise ValidationError(
|
raise ValidationError(_(msg))
|
||||||
_("Enter a valid sub. This value should be printable ASCII only.")
|
|
||||||
)
|
|
||||||
|
|||||||
@@ -508,6 +508,7 @@ class Base(Configuration):
|
|||||||
EMAIL_PORT = values.PositiveIntegerValue(None)
|
EMAIL_PORT = values.PositiveIntegerValue(None)
|
||||||
EMAIL_USE_TLS = values.BooleanValue(False)
|
EMAIL_USE_TLS = values.BooleanValue(False)
|
||||||
EMAIL_USE_SSL = values.BooleanValue(False)
|
EMAIL_USE_SSL = values.BooleanValue(False)
|
||||||
|
EMAIL_TIMEOUT = values.PositiveIntegerValue(30)
|
||||||
EMAIL_FROM = values.Value("from@example.com")
|
EMAIL_FROM = values.Value("from@example.com")
|
||||||
EMAIL_BRAND_NAME = values.Value(None)
|
EMAIL_BRAND_NAME = values.Value(None)
|
||||||
EMAIL_SUPPORT_EMAIL = values.Value(None)
|
EMAIL_SUPPORT_EMAIL = values.Value(None)
|
||||||
@@ -708,6 +709,20 @@ class Base(Configuration):
|
|||||||
default=None, environ_name="OIDC_RS_SCOPES_PREFIX", environ_prefix=None
|
default=None, environ_name="OIDC_RS_SCOPES_PREFIX", environ_prefix=None
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Menshen, La Suite's OAuth 2.0 token exchange server
|
||||||
|
MENSHEN_ENABLED = values.BooleanValue(
|
||||||
|
False, environ_name="MENSHEN_ENABLED", environ_prefix=None
|
||||||
|
)
|
||||||
|
MENSHEN_SERVER_URL = values.Value(
|
||||||
|
None, environ_name="MENSHEN_SERVER_URL", environ_prefix=None
|
||||||
|
)
|
||||||
|
MENSHEN_CLIENT_ID = values.Value(
|
||||||
|
None, environ_name="MENSHEN_CLIENT_ID", environ_prefix=None
|
||||||
|
)
|
||||||
|
MENSHEN_CLIENT_SECRET = SecretFileValue(
|
||||||
|
None, environ_name="MENSHEN_CLIENT_SECRET", environ_prefix=None
|
||||||
|
)
|
||||||
|
|
||||||
# Video conference configuration
|
# Video conference configuration
|
||||||
LIVEKIT_CONFIGURATION = {
|
LIVEKIT_CONFIGURATION = {
|
||||||
"api_key": SecretFileValue(environ_name="LIVEKIT_API_KEY", environ_prefix=None),
|
"api_key": SecretFileValue(environ_name="LIVEKIT_API_KEY", environ_prefix=None),
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ build-backend = "uv_build"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "meet"
|
name = "meet"
|
||||||
version = "1.34.0"
|
version = "1.35.0"
|
||||||
authors = [{ "name" = "DINUM", "email" = "dev@mail.numerique.gouv.fr" }]
|
authors = [{ "name" = "DINUM", "email" = "dev@mail.numerique.gouv.fr" }]
|
||||||
classifiers = [
|
classifiers = [
|
||||||
"Development Status :: 5 - Production/Stable",
|
"Development Status :: 5 - Production/Stable",
|
||||||
@@ -49,6 +49,7 @@ dependencies = [
|
|||||||
"gunicorn==26.2.0",
|
"gunicorn==26.2.0",
|
||||||
"jsonschema==4.26.0",
|
"jsonschema==4.26.0",
|
||||||
"markdown==3.10.3",
|
"markdown==3.10.3",
|
||||||
|
"menshen-client==0.2.0",
|
||||||
"nested-multipart-parser==1.6.0",
|
"nested-multipart-parser==1.6.0",
|
||||||
"posthog==7.44.0",
|
"posthog==7.44.0",
|
||||||
"psycopg[binary]==3.3.4",
|
"psycopg[binary]==3.3.4",
|
||||||
|
|||||||
Generated
+16
-2
@@ -1,5 +1,5 @@
|
|||||||
version = 1
|
version = 1
|
||||||
revision = 3
|
revision = 5
|
||||||
requires-python = ">=3.13"
|
requires-python = ">=3.13"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1297,7 +1297,7 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "meet"
|
name = "meet"
|
||||||
version = "1.34.0"
|
version = "1.35.0"
|
||||||
source = { editable = "." }
|
source = { editable = "." }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "aiohttp" },
|
{ name = "aiohttp" },
|
||||||
@@ -1327,6 +1327,7 @@ dependencies = [
|
|||||||
{ name = "jsonschema" },
|
{ name = "jsonschema" },
|
||||||
{ name = "livekit-api" },
|
{ name = "livekit-api" },
|
||||||
{ name = "markdown" },
|
{ name = "markdown" },
|
||||||
|
{ name = "menshen-client" },
|
||||||
{ name = "mozilla-django-oidc" },
|
{ name = "mozilla-django-oidc" },
|
||||||
{ name = "nested-multipart-parser" },
|
{ name = "nested-multipart-parser" },
|
||||||
{ name = "phonenumbers" },
|
{ name = "phonenumbers" },
|
||||||
@@ -1392,6 +1393,7 @@ requires-dist = [
|
|||||||
{ name = "jsonschema", specifier = "==4.26.0" },
|
{ name = "jsonschema", specifier = "==4.26.0" },
|
||||||
{ name = "livekit-api", specifier = "==1.2.0" },
|
{ name = "livekit-api", specifier = "==1.2.0" },
|
||||||
{ name = "markdown", specifier = "==3.10.3" },
|
{ name = "markdown", specifier = "==3.10.3" },
|
||||||
|
{ name = "menshen-client", specifier = "==0.2.0" },
|
||||||
{ name = "mozilla-django-oidc", specifier = "==5.0.2" },
|
{ name = "mozilla-django-oidc", specifier = "==5.0.2" },
|
||||||
{ name = "nested-multipart-parser", specifier = "==1.6.0" },
|
{ name = "nested-multipart-parser", specifier = "==1.6.0" },
|
||||||
{ name = "phonenumbers", specifier = "==9.0.37" },
|
{ name = "phonenumbers", specifier = "==9.0.37" },
|
||||||
@@ -1428,6 +1430,18 @@ dev = [
|
|||||||
{ name = "types-requests", specifier = "==2.33.0.20260712" },
|
{ name = "types-requests", specifier = "==2.33.0.20260712" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "menshen-client"
|
||||||
|
version = "0.2.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "requests" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/57/a2/5862b0a39d5f96301e1daff146445f591b1980412c20417162f5cfd6aa34/menshen_client-0.2.0.tar.gz", hash = "sha256:548c4224c38b2c844a76325b5a4b65da520ea9eba23aafb004da0f430a7e4fcf", size = 6326, upload-time = "2026-10-13T10:19:34.344Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/53/4d/932efd81c340d12d07b1c33633c6e7a4a2d0c9086ac0da7ffe9a1ce17068/menshen_client-0.2.0-py3-none-any.whl", hash = "sha256:415eaba30184452bc393b528c6088ed6e37cbd00ce60716aef9e2d9bd0697221", size = 7149, upload-time = "2026-10-13T10:19:33.272Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "mozilla-django-oidc"
|
name = "mozilla-django-oidc"
|
||||||
version = "5.0.2"
|
version = "5.0.2"
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "meet",
|
"name": "meet",
|
||||||
"version": "1.34.0",
|
"version": "1.35.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "meet",
|
"name": "meet",
|
||||||
"version": "1.34.0",
|
"version": "1.35.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fontsource-variable/atkinson-hyperlegible-next": "5.3.0",
|
"@fontsource-variable/atkinson-hyperlegible-next": "5.3.0",
|
||||||
"@fontsource-variable/lexend": "5.3.0",
|
"@fontsource-variable/lexend": "5.3.0",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "meet",
|
"name": "meet",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.34.0",
|
"version": "1.35.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "panda codegen && vite",
|
"dev": "panda codegen && vite",
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "mail_mjml",
|
"name": "mail_mjml",
|
||||||
"version": "1.34.0",
|
"version": "1.35.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "mail_mjml",
|
"name": "mail_mjml",
|
||||||
"version": "1.34.0",
|
"version": "1.35.0",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@html-to/text-cli": "0.6.1",
|
"@html-to/text-cli": "0.6.1",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "mail_mjml",
|
"name": "mail_mjml",
|
||||||
"version": "1.34.0",
|
"version": "1.35.0",
|
||||||
"description": "An util to generate html and text django's templates from mjml templates",
|
"description": "An util to generate html and text django's templates from mjml templates",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "sdk",
|
"name": "sdk",
|
||||||
"version": "1.34.0",
|
"version": "1.35.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "sdk",
|
"name": "sdk",
|
||||||
"version": "1.34.0",
|
"version": "1.35.0",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"workspaces": [
|
"workspaces": [
|
||||||
"./library",
|
"./library",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "sdk",
|
"name": "sdk",
|
||||||
"version": "1.34.0",
|
"version": "1.35.0",
|
||||||
"author": "",
|
"author": "",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"description": "",
|
"description": "",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "summary"
|
name = "summary"
|
||||||
version = "1.34.0"
|
version = "1.35.0"
|
||||||
requires-python = ">=3.13"
|
requires-python = ">=3.13"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"fastapi[standard]>=0.105.0",
|
"fastapi[standard]>=0.105.0",
|
||||||
|
|||||||
Generated
+1
-1
@@ -1484,7 +1484,7 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "summary"
|
name = "summary"
|
||||||
version = "1.34.0"
|
version = "1.35.0"
|
||||||
source = { editable = "." }
|
source = { editable = "." }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "boto3" },
|
{ name = "boto3" },
|
||||||
|
|||||||
Reference in New Issue
Block a user