mirror of
https://github.com/suitenumerique/meet.git
synced 2026-09-30 14:38:33 +00:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 387269d1d2 |
+1
-18
@@ -8,23 +8,6 @@ and this project adheres to
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Changed
|
||||
|
||||
- ⚡️(backend) hash application secrets with SHA-256
|
||||
|
||||
### Fixed
|
||||
|
||||
- 🔒️(backend) prevent editing client id and secret in Django admin
|
||||
|
||||
## [1.32.1] - 2026-09-25
|
||||
|
||||
### Fixed
|
||||
|
||||
- 🔒️(backend) fix CVE-2026-73228 and CVE-2026-73229 in drf
|
||||
- 🔒️(agent) fix CRITICAL CVE-2026-63072 / CVE-2026-63073 in libssl3t64
|
||||
|
||||
## [1.32.0] - 2026-09-25
|
||||
|
||||
### Added
|
||||
|
||||
- ✨(backend) make the LiveKit default video codec configurable
|
||||
@@ -45,7 +28,7 @@ and this project adheres to
|
||||
- ⬆️(frontend) upgrade posthog-js from 1.414.0 to 1.418.10
|
||||
- ⬆️(addons) upgrade i18next from 26.3.6 to 26.4.0
|
||||
- ⬆️(frontend) upgrade humanize-duration from 3.33.2 to 3.34.1
|
||||
- ⬆️(addons) upgrade i18next from 26.4.0 to 26.4.2
|
||||
- ⬆️(addons) upgrade i18next from 26.4.0 to 26.4.1
|
||||
- 🔖(helm) release chart 0.0.28
|
||||
- ♻️(backend) decouple recording event handling from LiveKit egress statuses
|
||||
|
||||
|
||||
Generated
+4
-4
@@ -10,7 +10,7 @@
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"core-js": "3.50.0",
|
||||
"i18next": "26.4.2",
|
||||
"i18next": "26.4.1",
|
||||
"i18next-browser-languagedetector": "8.2.1",
|
||||
"regenerator-runtime": "0.14.1"
|
||||
},
|
||||
@@ -9367,9 +9367,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/i18next": {
|
||||
"version": "26.4.2",
|
||||
"resolved": "https://registry.npmjs.org/i18next/-/i18next-26.4.2.tgz",
|
||||
"integrity": "sha512-RX+R0VLg13IbvRuJSxnqykUFS9vQZTl8wYpWPCIUDWVrSGjsQywB5Y+pjzrkboxGAuYfJZVH1InFTdgBdxq6ug==",
|
||||
"version": "26.4.1",
|
||||
"resolved": "https://registry.npmjs.org/i18next/-/i18next-26.4.1.tgz",
|
||||
"integrity": "sha512-9YbX5E6gd1H+yaOSX3izCsPj5iWXyH7X4oC+iuHHJJw8AeHglzOK5SJf+1CHxaYKYigcea+8jvzSxqYx46YvyA==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "individual",
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"core-js": "3.50.0",
|
||||
"i18next": "26.4.2",
|
||||
"i18next": "26.4.1",
|
||||
"i18next-browser-languagedetector": "8.2.1",
|
||||
"regenerator-runtime": "0.14.1"
|
||||
},
|
||||
|
||||
@@ -5,7 +5,6 @@ RUN sed -i "s|^URIs: http://|URIs: https://|" /etc/apt/sources.list.d/debian.sou
|
||||
&& apt-get update && apt-get install -y --no-install-recommends \
|
||||
libglib2.0-0 \
|
||||
libgobject-2.0-0 \
|
||||
libssl3t64 \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
|
||||
[project]
|
||||
name = "agents"
|
||||
version = "1.32.1"
|
||||
version = "1.31.0"
|
||||
requires-python = ">=3.12"
|
||||
dependencies = [
|
||||
"livekit-agents==1.6.7",
|
||||
|
||||
Generated
+1
-1
@@ -9,7 +9,7 @@ resolution-markers = [
|
||||
|
||||
[[package]]
|
||||
name = "agents"
|
||||
version = "1.32.1"
|
||||
version = "1.31.0"
|
||||
source = { virtual = "." }
|
||||
dependencies = [
|
||||
{ name = "httpx" },
|
||||
|
||||
@@ -476,11 +476,6 @@ class ApplicationAdminForm(forms.ModelForm):
|
||||
if self.instance.pk and self.instance.scopes:
|
||||
self.fields["scopes"].initial = self.instance.scopes
|
||||
|
||||
# On creation: display generated credentials without allowing edits
|
||||
for name in ("client_id", "client_secret"):
|
||||
if name in self.fields:
|
||||
self.fields[name].widget.attrs["readonly"] = True
|
||||
|
||||
|
||||
@admin.register(models.Application)
|
||||
class ApplicationAdmin(admin.ModelAdmin):
|
||||
|
||||
@@ -76,6 +76,9 @@ def get_frontend_configuration(request):
|
||||
"authenticated_users_can_edit_display_name": (
|
||||
settings.AUTHENTICATED_PARTICIPANTS_CAN_EDIT_DISPLAY_NAME
|
||||
),
|
||||
"encryption": {
|
||||
"is_enabled": settings.ENCRYPTION_ENABLED,
|
||||
},
|
||||
}
|
||||
frontend_configuration.update(settings.FRONTEND_CONFIGURATION)
|
||||
return Response(frontend_configuration)
|
||||
|
||||
@@ -38,6 +38,7 @@ class UserSerializer(serializers.ModelSerializer):
|
||||
"short_name",
|
||||
"timezone",
|
||||
"language",
|
||||
"default_encryption_mode",
|
||||
"default_room_access_level",
|
||||
"default_room_configuration",
|
||||
]
|
||||
@@ -53,6 +54,14 @@ class UserSerializer(serializers.ModelSerializer):
|
||||
raise serializers.ValidationError(e.errors()) from e
|
||||
return value
|
||||
|
||||
def validate_default_encryption_mode(self, value):
|
||||
"""Reject a non-none default when the server has encryption disabled."""
|
||||
if value != models.EncryptionMode.NONE and not settings.ENCRYPTION_ENABLED:
|
||||
raise serializers.ValidationError(
|
||||
_("End-to-end encryption is disabled on this server.")
|
||||
)
|
||||
return value
|
||||
|
||||
|
||||
class UserLightSerializer(serializers.ModelSerializer):
|
||||
"""Serialize users with limited fields."""
|
||||
@@ -94,6 +103,7 @@ class ResourceAccessSerializerMixin:
|
||||
raise PermissionDenied(
|
||||
"Only owners of a room can assign other users as owners."
|
||||
)
|
||||
|
||||
return data
|
||||
|
||||
def validate_resource(self, resource):
|
||||
@@ -148,7 +158,15 @@ class RoomSerializer(serializers.ModelSerializer):
|
||||
|
||||
class Meta:
|
||||
model = models.Room
|
||||
fields = ["id", "name", "slug", "configuration", "access_level", "pin_code"]
|
||||
fields = [
|
||||
"id",
|
||||
"name",
|
||||
"slug",
|
||||
"configuration",
|
||||
"access_level",
|
||||
"pin_code",
|
||||
"encryption_mode",
|
||||
]
|
||||
read_only_fields = ["id", "slug", "pin_code"]
|
||||
|
||||
def validate_configuration(self, value):
|
||||
@@ -161,6 +179,32 @@ class RoomSerializer(serializers.ModelSerializer):
|
||||
raise serializers.ValidationError(e.errors()) from e
|
||||
return value
|
||||
|
||||
def validate_encryption_mode(self, value):
|
||||
"""Encryption mode is part of the link's semantics (the passphrase
|
||||
lives in the URL hash for `basic` rooms) so it cannot be changed once
|
||||
the room exists."""
|
||||
instance = self.instance
|
||||
if instance and instance.encryption_mode != value:
|
||||
raise serializers.ValidationError(
|
||||
"Encryption mode cannot be changed after room creation."
|
||||
)
|
||||
return value
|
||||
|
||||
def validate_access_level(self, value):
|
||||
"""Encrypted rooms must stay restricted — the lobby is the only way
|
||||
to enforce per-participant admission, and basic encryption relies on
|
||||
the host vetting each joiner before they receive the in-URL key."""
|
||||
instance = self.instance
|
||||
if (
|
||||
instance
|
||||
and instance.encryption_mode != models.EncryptionMode.NONE
|
||||
and value != models.RoomAccessLevel.RESTRICTED
|
||||
):
|
||||
raise serializers.ValidationError(
|
||||
"Encrypted rooms require restricted access level."
|
||||
)
|
||||
return value
|
||||
|
||||
def to_representation(self, instance):
|
||||
"""
|
||||
Add users only for administrator users.
|
||||
@@ -197,12 +241,14 @@ class RoomSerializer(serializers.ModelSerializer):
|
||||
if should_access_room:
|
||||
room_id = f"{instance.id!s}"
|
||||
username = request.query_params.get("username", None)
|
||||
|
||||
output["livekit"] = utils.generate_livekit_config(
|
||||
room_id=room_id,
|
||||
user=request.user,
|
||||
username=username,
|
||||
configuration=output["configuration"],
|
||||
role=role,
|
||||
encryption_mode=instance.encryption_mode,
|
||||
)
|
||||
else:
|
||||
del output["pin_code"]
|
||||
|
||||
@@ -249,6 +249,18 @@ class RoomViewSet(
|
||||
Apply the user's default room preferences (access level and configuration)
|
||||
unless the request explicitly provides its own values.
|
||||
"""
|
||||
encryption_mode = serializer.validated_data.get(
|
||||
"encryption_mode", models.EncryptionMode.NONE
|
||||
)
|
||||
|
||||
if (
|
||||
encryption_mode != models.EncryptionMode.NONE
|
||||
and not settings.ENCRYPTION_ENABLED
|
||||
):
|
||||
raise drf_exceptions.ValidationError(
|
||||
{"encryption_mode": "Encryption is not enabled on this server."}
|
||||
)
|
||||
|
||||
user = self.request.user
|
||||
save_kwargs = {}
|
||||
|
||||
@@ -313,16 +325,21 @@ class RoomViewSet(
|
||||
"""Start recording a room."""
|
||||
|
||||
serializer = serializers.StartRecordingSerializer(data=request.data)
|
||||
|
||||
if not serializer.is_valid():
|
||||
return drf_response.Response(
|
||||
{"detail": "Invalid request."}, status=drf_status.HTTP_400_BAD_REQUEST
|
||||
{"detail": "Invalid request."},
|
||||
status=drf_status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
mode = serializer.validated_data["mode"]
|
||||
options = serializer.validated_data.get("options")
|
||||
room = self.get_object()
|
||||
|
||||
if room.is_encrypted:
|
||||
raise drf_exceptions.ValidationError(
|
||||
{"detail": "Recording is unavailable in encrypted rooms."}
|
||||
)
|
||||
|
||||
try:
|
||||
with transaction.atomic():
|
||||
recording = models.Recording.objects.create(
|
||||
@@ -645,6 +662,11 @@ class RoomViewSet(
|
||||
|
||||
room = self.get_object()
|
||||
|
||||
if room.is_encrypted:
|
||||
raise drf_exceptions.ValidationError(
|
||||
{"detail": "Subtitles are unavailable in encrypted rooms."}
|
||||
)
|
||||
|
||||
try:
|
||||
SubtitleService().start_subtitle(room)
|
||||
except SubtitleException:
|
||||
|
||||
@@ -5,6 +5,7 @@ Core application enums declaration
|
||||
import re
|
||||
|
||||
from django.conf import global_settings, settings
|
||||
from django.db import models
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
UUID_REGEX = (
|
||||
@@ -32,3 +33,14 @@ ALL_LANGUAGES = getattr(
|
||||
"ALL_LANGUAGES",
|
||||
[(language, _(name)) for language, name in global_settings.LANGUAGES],
|
||||
)
|
||||
|
||||
|
||||
class EncryptionMode(models.TextChoices):
|
||||
"""Encryption mode for a room.
|
||||
|
||||
Kept as an enum (not a boolean) so future modes — e.g. a vault-managed
|
||||
per-user key flow — can be added without another schema migration.
|
||||
"""
|
||||
|
||||
NONE = "none", _("No encryption")
|
||||
BASIC = "basic", _("Passphrase-in-URL encryption")
|
||||
|
||||
@@ -4,6 +4,7 @@ import copy
|
||||
from logging import getLogger
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.hashers import check_password
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.core.validators import validate_email
|
||||
|
||||
@@ -73,7 +74,7 @@ class ApplicationViewSet(viewsets.ViewSet):
|
||||
except models.Application.DoesNotExist as e:
|
||||
raise drf_exceptions.AuthenticationFailed("Invalid credentials") from e
|
||||
|
||||
if not application.check_client_secret(client_secret):
|
||||
if not check_password(client_secret, application.client_secret):
|
||||
raise drf_exceptions.AuthenticationFailed("Invalid credentials")
|
||||
|
||||
if not application.is_active:
|
||||
|
||||
@@ -7,8 +7,6 @@ from logging import getLogger
|
||||
from django.contrib.auth.hashers import identify_hasher, make_password
|
||||
from django.db import models
|
||||
|
||||
from .hashers import CLIENT_SECRET_HASH_PATTERN
|
||||
|
||||
logger = getLogger(__name__)
|
||||
|
||||
|
||||
@@ -26,14 +24,6 @@ class SecretField(models.CharField):
|
||||
|
||||
secret = getattr(model_instance, self.attname)
|
||||
|
||||
if CLIENT_SECRET_HASH_PATTERN.fullmatch(secret):
|
||||
logger.debug(
|
||||
"%s: %s is already hashed with sha256.",
|
||||
model_instance,
|
||||
self.attname,
|
||||
)
|
||||
return secret
|
||||
|
||||
try:
|
||||
hasher = identify_hasher(secret)
|
||||
logger.debug(
|
||||
|
||||
@@ -1,46 +0,0 @@
|
||||
"""Application secrets only: keep fast hashing out of PASSWORD_HASHERS.
|
||||
|
||||
Secrets must be securely randomly generated, not human-chosen.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import re
|
||||
|
||||
from django.contrib.auth.hashers import check_password
|
||||
from django.utils.crypto import constant_time_compare
|
||||
from django.utils.encoding import force_bytes
|
||||
|
||||
CLIENT_SECRET_HASH_ALGORITHM = "sha256"
|
||||
CLIENT_SECRET_HASH_VERSION = "v0"
|
||||
CLIENT_SECRET_HASH_PREFIX = ( # noqa: S105 - format identifier, not a secret
|
||||
f"{CLIENT_SECRET_HASH_ALGORITHM}${CLIENT_SECRET_HASH_VERSION}$"
|
||||
)
|
||||
|
||||
# Accept only the versioned format: sha256$v0$<digest>.
|
||||
CLIENT_SECRET_HASH_PATTERN = re.compile(
|
||||
rf"{re.escape(CLIENT_SECRET_HASH_PREFIX)}(?P<digest>[0-9a-f]{{64}})"
|
||||
)
|
||||
|
||||
|
||||
def _digest(raw_secret):
|
||||
"""Return the hex SHA-256 digest of a raw secret."""
|
||||
return hashlib.sha256(force_bytes(raw_secret)).hexdigest()
|
||||
|
||||
|
||||
def hash_client_secret(raw_secret):
|
||||
"""Hash a machine-generated application secret without key stretching."""
|
||||
return f"{CLIENT_SECRET_HASH_PREFIX}{_digest(raw_secret)}"
|
||||
|
||||
|
||||
def verify_client_secret(raw_secret, encoded):
|
||||
"""Verify the versioned application format or a legacy Django password hash."""
|
||||
if raw_secret is None:
|
||||
return False
|
||||
|
||||
match = CLIENT_SECRET_HASH_PATTERN.fullmatch(encoded)
|
||||
|
||||
# Legacy path
|
||||
if not match:
|
||||
return check_password(raw_secret, encoded)
|
||||
|
||||
return constant_time_compare(match["digest"], _digest(raw_secret))
|
||||
@@ -1,19 +0,0 @@
|
||||
"""Add a separate fast hash while preserving legacy credentials for rollback."""
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("core", "0022_user_default_room_access_level_and_more"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="application",
|
||||
name="client_secret_sha256",
|
||||
field=models.CharField(
|
||||
max_length=255, null=True, blank=True
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,46 @@
|
||||
"""Add Room.encryption_mode and User.default_encryption_mode (enum-based).
|
||||
|
||||
We store the mode as an enum (CharField with choices) rather than a boolean
|
||||
so a future "advanced" mode (per-user vault keys, etc.) can be added without
|
||||
a schema migration.
|
||||
"""
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("core", "0023_alter_recording_status"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="room",
|
||||
name="encryption_mode",
|
||||
field=models.CharField(
|
||||
choices=[
|
||||
("none", "No encryption"),
|
||||
("basic", "Passphrase-in-URL encryption"),
|
||||
],
|
||||
default="none",
|
||||
help_text="End-to-end encryption mode for this room.",
|
||||
max_length=20,
|
||||
verbose_name="Encryption mode",
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="user",
|
||||
name="default_encryption_mode",
|
||||
field=models.CharField(
|
||||
choices=[
|
||||
("none", "No encryption"),
|
||||
("basic", "Passphrase-in-URL encryption"),
|
||||
],
|
||||
default="none",
|
||||
help_text="Encryption mode pre-selected when this user creates a new meeting.",
|
||||
max_length=20,
|
||||
verbose_name="Default encryption mode",
|
||||
),
|
||||
),
|
||||
]
|
||||
+74
-65
@@ -14,7 +14,6 @@ from typing import List, Optional
|
||||
from django.conf import settings
|
||||
from django.contrib.auth import models as auth_models
|
||||
from django.contrib.auth.base_user import AbstractBaseUser
|
||||
from django.contrib.auth.hashers import identify_hasher
|
||||
from django.contrib.postgres.fields import ArrayField
|
||||
from django.core import mail, validators
|
||||
from django.core.exceptions import PermissionDenied, ValidationError
|
||||
@@ -26,7 +25,8 @@ from django.utils.translation import gettext_lazy as _
|
||||
from lasuite.tools.email import get_domain_from_email
|
||||
from timezone_field import TimeZoneField
|
||||
|
||||
from . import fields, hashers, utils
|
||||
from . import fields, utils
|
||||
from .enums import EncryptionMode
|
||||
from .recording.enums import FileExtension
|
||||
from .validators import sub_validator
|
||||
|
||||
@@ -217,6 +217,15 @@ class User(AbstractBaseUser, BaseModel, auth_models.PermissionsMixin):
|
||||
"Unselect this instead of deleting accounts."
|
||||
),
|
||||
)
|
||||
default_encryption_mode = models.CharField(
|
||||
_("Default encryption mode"),
|
||||
max_length=20,
|
||||
choices=EncryptionMode.choices,
|
||||
default=EncryptionMode.NONE,
|
||||
help_text=_(
|
||||
"Encryption mode pre-selected when this user creates a new meeting."
|
||||
),
|
||||
)
|
||||
|
||||
objects = auth_models.UserManager()
|
||||
|
||||
@@ -412,6 +421,13 @@ class Room(Resource):
|
||||
choices=RoomAccessLevel.choices,
|
||||
default=settings.RESOURCE_DEFAULT_ACCESS_LEVEL,
|
||||
)
|
||||
encryption_mode = models.CharField(
|
||||
max_length=20,
|
||||
choices=EncryptionMode.choices,
|
||||
default=EncryptionMode.NONE,
|
||||
verbose_name=_("Encryption mode"),
|
||||
help_text=_("End-to-end encryption mode for this room."),
|
||||
)
|
||||
# Public configuration exposed to any room participant via the API
|
||||
configuration = models.JSONField(
|
||||
blank=True,
|
||||
@@ -438,20 +454,68 @@ class Room(Resource):
|
||||
return capfirst(self.name)
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
"""Generate a unique n-digit pin code for new rooms."""
|
||||
"""Restrict new encrypted rooms and allocate PINs for unencrypted rooms.
|
||||
|
||||
# Roomkit devices also join by PIN, so a PIN is needed as soon as
|
||||
# either integration is enabled.
|
||||
Skip PIN allocation for encrypted rooms — the SIP gateway will
|
||||
always reject calls to them (no way to derive the key), and the
|
||||
PIN namespace is finite (10**length): no point burning slots that
|
||||
can never be dialed.
|
||||
|
||||
Also run `clean()` so the encryption invariants are enforced on
|
||||
every save path (ORM, admin, shell), not only via the DRF
|
||||
serializer.
|
||||
"""
|
||||
# Override both explicit access levels and user defaults on creation.
|
||||
# Updates remain subject to clean() instead of being silently normalized.
|
||||
if self._state.adding and self.is_encrypted:
|
||||
self.access_level = RoomAccessLevel.RESTRICTED
|
||||
self.clean()
|
||||
if (
|
||||
(settings.ROOM_TELEPHONY_ENABLED or settings.ROOMKIT_ENABLED)
|
||||
and not self.pk
|
||||
and not self.pin_code
|
||||
and self.encryption_mode == EncryptionMode.NONE
|
||||
):
|
||||
self.pin_code = self.generate_unique_pin_code(
|
||||
length=settings.ROOM_TELEPHONY_PIN_LENGTH
|
||||
)
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
def clean(self):
|
||||
"""Enforce encryption-mode invariants outside DRF.
|
||||
|
||||
Two rules:
|
||||
- `encryption_mode` is set at creation and never mutated afterwards
|
||||
(the URL-hash passphrase encodes assumptions about it).
|
||||
- An encrypted room must be at the RESTRICTED access level so the
|
||||
host vets joiners before they ever see the in-URL key.
|
||||
"""
|
||||
super().clean()
|
||||
if self.pk is not None:
|
||||
previous = Room.objects.filter(pk=self.pk).only("encryption_mode").first()
|
||||
if (
|
||||
previous is not None
|
||||
and previous.encryption_mode != self.encryption_mode
|
||||
):
|
||||
raise ValidationError(
|
||||
{
|
||||
"encryption_mode": _(
|
||||
"Encryption mode cannot be changed after room creation."
|
||||
)
|
||||
}
|
||||
)
|
||||
if (
|
||||
self.encryption_mode != EncryptionMode.NONE
|
||||
and self.access_level != RoomAccessLevel.RESTRICTED
|
||||
):
|
||||
raise ValidationError(
|
||||
{
|
||||
"access_level": _(
|
||||
"Encrypted rooms must use the 'restricted' access level."
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
def clean_fields(self, exclude=None):
|
||||
"""
|
||||
Automatically generate the slug from the name and make sure it does not look like a UUID.
|
||||
@@ -474,6 +538,11 @@ class Room(Resource):
|
||||
"""Check if a room is public"""
|
||||
return self.access_level == RoomAccessLevel.PUBLIC
|
||||
|
||||
@property
|
||||
def is_encrypted(self):
|
||||
"""Convenience: any non-none encryption mode counts as encrypted."""
|
||||
return self.encryption_mode != EncryptionMode.NONE
|
||||
|
||||
@staticmethod
|
||||
def generate_unique_pin_code(length):
|
||||
"""Generate a unique n-digit PIN code"""
|
||||
@@ -812,9 +881,6 @@ class Application(BaseModel):
|
||||
default=utils.generate_client_secret,
|
||||
help_text=_("Hashed on Save. Copy it now if this is a new secret."),
|
||||
)
|
||||
client_secret_sha256 = models.CharField(
|
||||
max_length=255, null=True, blank=True, editable=False
|
||||
)
|
||||
scopes = ArrayField(
|
||||
models.CharField(max_length=50, choices=ApplicationScope.choices),
|
||||
default=list,
|
||||
@@ -830,63 +896,6 @@ class Application(BaseModel):
|
||||
def __str__(self):
|
||||
return f"{self.name!s}"
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
"""Populate the fast hash on creation when the raw secret is available."""
|
||||
if self._state.adding:
|
||||
# Prevent hashing an existing hash instead of the original secret
|
||||
try:
|
||||
if not hashers.CLIENT_SECRET_HASH_PATTERN.fullmatch(self.client_secret):
|
||||
identify_hasher(self.client_secret)
|
||||
except ValueError:
|
||||
# SecretField.pre_save hashes the legacy field after this method
|
||||
self.client_secret_sha256 = hashers.hash_client_secret(
|
||||
self.client_secret
|
||||
)
|
||||
|
||||
return super().save(*args, **kwargs)
|
||||
|
||||
def rotate_client_secret(self):
|
||||
"""Persist a new generated secret and return its raw value to the caller.
|
||||
|
||||
This is the only supported rotation path while both credential fields coexist.
|
||||
Direct writes may leave a stale fast hash that still accepts the revoked secret,
|
||||
while saving a stale instance may restore previous credentials.
|
||||
|
||||
This transitional risk is accepted until the legacy field is removed
|
||||
and rotation writes only the fast hash.
|
||||
"""
|
||||
secret = utils.generate_client_secret()
|
||||
self.client_secret = secret
|
||||
self.client_secret_sha256 = hashers.hash_client_secret(secret)
|
||||
self.save(update_fields=["client_secret", "client_secret_sha256"])
|
||||
return secret
|
||||
|
||||
def check_client_secret(self, raw_secret):
|
||||
"""Verify the secret and lazily populate its fast hash for future logins."""
|
||||
if self.client_secret_sha256 is not None:
|
||||
return hashers.verify_client_secret(raw_secret, self.client_secret_sha256)
|
||||
|
||||
original_hash = self.client_secret
|
||||
if not hashers.verify_client_secret(raw_secret, original_hash):
|
||||
return False
|
||||
|
||||
encoded = hashers.hash_client_secret(raw_secret)
|
||||
updated = Application.objects.filter(
|
||||
pk=self.pk, client_secret=original_hash, client_secret_sha256__isnull=True
|
||||
).update(client_secret_sha256=encoded)
|
||||
|
||||
if updated:
|
||||
self.client_secret_sha256 = encoded
|
||||
return True
|
||||
|
||||
try:
|
||||
self.refresh_from_db()
|
||||
except Application.DoesNotExist:
|
||||
return False
|
||||
|
||||
current_hash = self.client_secret_sha256 or self.client_secret
|
||||
return hashers.verify_client_secret(raw_secret, current_hash)
|
||||
|
||||
def can_delegate_email(self, email):
|
||||
"""Check if this application can delegate the given email."""
|
||||
|
||||
|
||||
@@ -275,7 +275,9 @@ class LiveKitEventsService:
|
||||
except models.Room.DoesNotExist as err:
|
||||
raise ActionFailedError(f"Room with ID {room_id} does not exist") from err
|
||||
|
||||
if settings.ROOM_TELEPHONY_ENABLED or settings.ROOMKIT_ENABLED:
|
||||
if (
|
||||
settings.ROOM_TELEPHONY_ENABLED or settings.ROOMKIT_ENABLED
|
||||
) and not room.is_encrypted:
|
||||
try:
|
||||
self.sip_management.ensure_dispatch_rule(room)
|
||||
except SIPException as e:
|
||||
|
||||
@@ -48,8 +48,11 @@ class LobbyParticipant:
|
||||
color: str
|
||||
id: str
|
||||
entered_at: str
|
||||
# Whether the user signed in (e.g. via ProConnect). Surfaced to admins so
|
||||
# they can decide whether to accept self-declared identities.
|
||||
is_authenticated: bool = False
|
||||
|
||||
def to_dict(self) -> Dict[str, str]:
|
||||
def to_dict(self) -> Dict[str, object]:
|
||||
"""Serialize the participant object to a dict representation."""
|
||||
return {
|
||||
"status": self.status.value,
|
||||
@@ -57,6 +60,7 @@ class LobbyParticipant:
|
||||
"id": self.id,
|
||||
"color": self.color,
|
||||
"entered_at": self.entered_at,
|
||||
"is_authenticated": self.is_authenticated,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
@@ -72,6 +76,7 @@ class LobbyParticipant:
|
||||
id=data["id"],
|
||||
color=data["color"],
|
||||
entered_at=data["entered_at"],
|
||||
is_authenticated=bool(data.get("is_authenticated", False)),
|
||||
)
|
||||
except (KeyError, ValueError) as e:
|
||||
logger.exception("Error creating Participant from dict:")
|
||||
@@ -144,7 +149,7 @@ class LobbyService:
|
||||
key=settings.LOBBY_COOKIE_NAME,
|
||||
value=participant_id,
|
||||
httponly=True,
|
||||
secure=True,
|
||||
secure=not settings.DEBUG,
|
||||
samesite="Lax",
|
||||
)
|
||||
|
||||
@@ -208,6 +213,7 @@ class LobbyService:
|
||||
id=participant_id,
|
||||
color=utils.generate_color(participant_id),
|
||||
entered_at=timezone.now().isoformat(),
|
||||
is_authenticated=request.user.is_authenticated,
|
||||
)
|
||||
else:
|
||||
participant.status = LobbyParticipantStatus.ACCEPTED
|
||||
@@ -220,19 +226,24 @@ class LobbyService:
|
||||
configuration=room.configuration,
|
||||
participant_id=participant_id,
|
||||
role=user_role,
|
||||
encryption_mode=room.encryption_mode,
|
||||
)
|
||||
return participant, livekit_config
|
||||
|
||||
livekit_config = None
|
||||
|
||||
if participant is None:
|
||||
participant = self.enter(room.id, participant_id, username)
|
||||
participant = self.enter(
|
||||
room.id,
|
||||
participant_id,
|
||||
username,
|
||||
is_authenticated=request.user.is_authenticated,
|
||||
)
|
||||
|
||||
elif participant.status == LobbyParticipantStatus.WAITING:
|
||||
self.refresh_waiting_status(room.id, participant_id)
|
||||
|
||||
elif participant.status == LobbyParticipantStatus.ACCEPTED:
|
||||
# wrongly named, contains access token to join a room
|
||||
livekit_config = utils.generate_livekit_config(
|
||||
room_id=room_id,
|
||||
user=request.user,
|
||||
@@ -241,6 +252,7 @@ class LobbyService:
|
||||
configuration=room.configuration,
|
||||
participant_id=participant_id,
|
||||
role=user_role,
|
||||
encryption_mode=room.encryption_mode,
|
||||
)
|
||||
|
||||
return participant, livekit_config
|
||||
@@ -258,7 +270,11 @@ class LobbyService:
|
||||
self._index_touch(room_id)
|
||||
|
||||
def enter(
|
||||
self, room_id: UUID, participant_id: str, username: str
|
||||
self,
|
||||
room_id: UUID,
|
||||
participant_id: str,
|
||||
username: str,
|
||||
is_authenticated: bool = False,
|
||||
) -> LobbyParticipant:
|
||||
"""Add participant to waiting lobby."""
|
||||
|
||||
@@ -270,6 +286,7 @@ class LobbyService:
|
||||
id=participant_id,
|
||||
color=color,
|
||||
entered_at=timezone.now().isoformat(),
|
||||
is_authenticated=is_authenticated,
|
||||
)
|
||||
|
||||
try:
|
||||
|
||||
@@ -312,3 +312,34 @@ def test_api_rooms_create_authenticated_blank_user_default_access_level():
|
||||
assert response.status_code == 201
|
||||
room = Room.objects.get()
|
||||
assert room.access_level == settings.RESOURCE_DEFAULT_ACCESS_LEVEL
|
||||
|
||||
|
||||
@pytest.mark.parametrize("encryption_mode", ["none", "basic"])
|
||||
@pytest.mark.parametrize(
|
||||
"user_default", [None, RoomAccessLevel.PUBLIC, RoomAccessLevel.TRUSTED]
|
||||
)
|
||||
@pytest.mark.parametrize(
|
||||
"requested_access", [None, RoomAccessLevel.PUBLIC, RoomAccessLevel.TRUSTED]
|
||||
)
|
||||
def test_api_rooms_create_encryption_access_precedence(
|
||||
settings, encryption_mode, user_default, requested_access
|
||||
):
|
||||
"""Encryption overrides request and user access defaults only for encrypted rooms."""
|
||||
settings.ENCRYPTION_ENABLED = True
|
||||
user = UserFactory(default_room_access_level=user_default)
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
data = {"name": "New room", "encryption_mode": encryption_mode}
|
||||
if requested_access is not None:
|
||||
data["access_level"] = requested_access
|
||||
|
||||
response = client.post("/api/v1.0/rooms/", data)
|
||||
|
||||
assert response.status_code == 201
|
||||
expected_access = (
|
||||
RoomAccessLevel.RESTRICTED
|
||||
if encryption_mode == "basic"
|
||||
else requested_access or user_default or settings.RESOURCE_DEFAULT_ACCESS_LEVEL
|
||||
)
|
||||
assert response.json()["access_level"] == expected_access
|
||||
assert Room.objects.get().access_level == expected_access
|
||||
|
||||
@@ -62,6 +62,7 @@ def test_request_entry_anonymous(settings):
|
||||
"status": "waiting",
|
||||
"color": "mocked-color",
|
||||
"entered_at": "2025-01-01T10:00:00+00:00",
|
||||
"is_authenticated": False,
|
||||
"livekit": None,
|
||||
}
|
||||
|
||||
@@ -75,9 +76,12 @@ def test_request_entry_anonymous(settings):
|
||||
|
||||
|
||||
@freeze_time("2025-01-01 10:00:00")
|
||||
def test_request_entry_authenticated_user(settings):
|
||||
@pytest.mark.parametrize("encryption_mode", ["none", "basic"])
|
||||
def test_request_entry_authenticated_user(settings, encryption_mode):
|
||||
"""Authenticated users should be allowed to request entry."""
|
||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||
room = RoomFactory(
|
||||
access_level=RoomAccessLevel.RESTRICTED, encryption_mode=encryption_mode
|
||||
)
|
||||
user = UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
@@ -113,6 +117,7 @@ def test_request_entry_authenticated_user(settings):
|
||||
"status": "waiting",
|
||||
"color": "mocked-color",
|
||||
"entered_at": "2025-01-01T10:00:00+00:00",
|
||||
"is_authenticated": True,
|
||||
"livekit": None,
|
||||
}
|
||||
|
||||
@@ -189,6 +194,7 @@ def test_request_entry_with_existing_participants(settings):
|
||||
"entered_at": "2025-01-01T10:00:00+00:00",
|
||||
"status": "waiting",
|
||||
"color": "mocked-color",
|
||||
"is_authenticated": False,
|
||||
"livekit": None,
|
||||
}
|
||||
|
||||
@@ -243,6 +249,7 @@ def test_request_entry_public_room(settings):
|
||||
"entered_at": "2025-01-01T10:00:00+00:00",
|
||||
"status": "accepted",
|
||||
"color": "mocked-color",
|
||||
"is_authenticated": False,
|
||||
"livekit": {"token": "test-token"},
|
||||
}
|
||||
|
||||
@@ -297,6 +304,7 @@ def test_request_entry_authenticated_user_public_room(settings):
|
||||
"entered_at": "2025-01-01T10:00:00+00:00",
|
||||
"status": "accepted",
|
||||
"color": "mocked-color",
|
||||
"is_authenticated": True,
|
||||
"livekit": {"token": "test-token"},
|
||||
}
|
||||
|
||||
@@ -354,6 +362,7 @@ def test_request_entry_waiting_participant_public_room(settings):
|
||||
"status": "accepted",
|
||||
"color": "#123456",
|
||||
"entered_at": "2025-01-01T10:00:00+00:00",
|
||||
"is_authenticated": False,
|
||||
"livekit": {"token": "test-token"},
|
||||
}
|
||||
|
||||
@@ -623,6 +632,7 @@ def test_list_waiting_participants_success(settings):
|
||||
"username": "user2",
|
||||
"status": "waiting",
|
||||
"color": "#654321",
|
||||
"is_authenticated": False,
|
||||
"entered_at": "2025-01-01T10:05:00+00:00",
|
||||
},
|
||||
{
|
||||
@@ -630,6 +640,7 @@ def test_list_waiting_participants_success(settings):
|
||||
"username": "user1",
|
||||
"status": "waiting",
|
||||
"color": "#123456",
|
||||
"is_authenticated": False,
|
||||
"entered_at": "2025-01-01T10:00:00+00:00",
|
||||
},
|
||||
]
|
||||
|
||||
@@ -33,6 +33,7 @@ def test_api_rooms_retrieve_anonymous_private_pk():
|
||||
"id": str(room.id),
|
||||
"name": room.name,
|
||||
"slug": room.slug,
|
||||
"encryption_mode": room.encryption_mode,
|
||||
}
|
||||
|
||||
|
||||
@@ -52,6 +53,7 @@ def test_api_rooms_retrieve_anonymous_trusted_pk():
|
||||
"id": str(room.id),
|
||||
"name": room.name,
|
||||
"slug": room.slug,
|
||||
"encryption_mode": room.encryption_mode,
|
||||
}
|
||||
|
||||
|
||||
@@ -70,6 +72,7 @@ def test_api_rooms_retrieve_anonymous_private_pk_no_dashes():
|
||||
"id": str(room.id),
|
||||
"name": room.name,
|
||||
"slug": room.slug,
|
||||
"encryption_mode": room.encryption_mode,
|
||||
}
|
||||
|
||||
|
||||
@@ -86,6 +89,7 @@ def test_api_rooms_retrieve_anonymous_private_slug():
|
||||
"id": str(room.id),
|
||||
"name": room.name,
|
||||
"slug": room.slug,
|
||||
"encryption_mode": room.encryption_mode,
|
||||
}
|
||||
|
||||
|
||||
@@ -102,6 +106,7 @@ def test_api_rooms_retrieve_anonymous_private_slug_not_normalized():
|
||||
"id": str(room.id),
|
||||
"name": room.name,
|
||||
"slug": room.slug,
|
||||
"encryption_mode": room.encryption_mode,
|
||||
}
|
||||
|
||||
|
||||
@@ -217,6 +222,7 @@ def test_api_rooms_retrieve_anonymous_public(mock_token):
|
||||
"name": room.name,
|
||||
"pin_code": room.pin_code,
|
||||
"slug": room.slug,
|
||||
"encryption_mode": room.encryption_mode,
|
||||
}
|
||||
|
||||
mock_token.assert_called_once()
|
||||
@@ -263,6 +269,7 @@ def test_api_rooms_retrieve_authenticated_public(mock_token):
|
||||
"name": room.name,
|
||||
"pin_code": room.pin_code,
|
||||
"slug": room.slug,
|
||||
"encryption_mode": room.encryption_mode,
|
||||
}
|
||||
|
||||
mock_token.assert_called_once_with(
|
||||
@@ -273,6 +280,7 @@ def test_api_rooms_retrieve_authenticated_public(mock_token):
|
||||
sources=["camera"],
|
||||
role=None,
|
||||
participant_id=None,
|
||||
encryption_mode="none",
|
||||
)
|
||||
|
||||
|
||||
@@ -314,6 +322,7 @@ def test_api_rooms_retrieve_authenticated_trusted(mock_token):
|
||||
"name": room.name,
|
||||
"pin_code": room.pin_code,
|
||||
"slug": room.slug,
|
||||
"encryption_mode": room.encryption_mode,
|
||||
}
|
||||
|
||||
mock_token.assert_called_once_with(
|
||||
@@ -324,6 +333,7 @@ def test_api_rooms_retrieve_authenticated_trusted(mock_token):
|
||||
sources=None,
|
||||
role=None,
|
||||
participant_id=None,
|
||||
encryption_mode="none",
|
||||
)
|
||||
|
||||
|
||||
@@ -349,6 +359,7 @@ def test_api_rooms_retrieve_authenticated():
|
||||
"id": str(room.id),
|
||||
"name": room.name,
|
||||
"slug": room.slug,
|
||||
"encryption_mode": room.encryption_mode,
|
||||
}
|
||||
|
||||
|
||||
@@ -400,6 +411,7 @@ def test_api_rooms_retrieve_members(mock_token, django_assert_num_queries, setti
|
||||
"name": room.name,
|
||||
"pin_code": room.pin_code,
|
||||
"slug": room.slug,
|
||||
"encryption_mode": room.encryption_mode,
|
||||
}
|
||||
|
||||
mock_token.assert_called_once_with(
|
||||
@@ -410,6 +422,7 @@ def test_api_rooms_retrieve_members(mock_token, django_assert_num_queries, setti
|
||||
sources=["camera"],
|
||||
role=str(RoleChoices.MEMBER),
|
||||
participant_id=None,
|
||||
encryption_mode="none",
|
||||
)
|
||||
|
||||
|
||||
@@ -461,6 +474,7 @@ def test_api_rooms_retrieve_administrators(
|
||||
"short_name": other_user_access.user.short_name,
|
||||
"timezone": "UTC",
|
||||
"language": other_user_access.user.language,
|
||||
"default_encryption_mode": "none",
|
||||
},
|
||||
"resource": str(room.id),
|
||||
"role": other_user_access.role,
|
||||
@@ -476,6 +490,7 @@ def test_api_rooms_retrieve_administrators(
|
||||
"short_name": user_access.user.short_name,
|
||||
"timezone": "UTC",
|
||||
"language": user_access.user.language,
|
||||
"default_encryption_mode": "none",
|
||||
},
|
||||
"resource": str(room.id),
|
||||
"role": user_access.role,
|
||||
@@ -496,6 +511,7 @@ def test_api_rooms_retrieve_administrators(
|
||||
"name": room.name,
|
||||
"pin_code": room.pin_code,
|
||||
"slug": room.slug,
|
||||
"encryption_mode": room.encryption_mode,
|
||||
}
|
||||
|
||||
mock_token.assert_called_once_with(
|
||||
@@ -506,4 +522,25 @@ def test_api_rooms_retrieve_administrators(
|
||||
sources=None,
|
||||
role=str(user_access.role),
|
||||
participant_id=None,
|
||||
encryption_mode="none",
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("encryption_mode", ["none", "basic"])
|
||||
@mock.patch("core.utils.generate_token", return_value="test-token")
|
||||
def test_api_rooms_retrieve_custom_username(mock_token, encryption_mode, settings):
|
||||
"""Encryption does not override the participant's requested display name."""
|
||||
settings.AUTHENTICATED_PARTICIPANTS_CAN_EDIT_DISPLAY_NAME = True
|
||||
user = UserFactory(full_name="Profile Name")
|
||||
room = RoomFactory(
|
||||
access_level=RoomAccessLevel.RESTRICTED, encryption_mode=encryption_mode
|
||||
)
|
||||
UserResourceAccessFactory(resource=room, user=user, role="owner")
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.get(f"/api/v1.0/rooms/{room.id}/", {"username": "Custom Name"})
|
||||
|
||||
assert response.status_code == 200
|
||||
assert mock_token.call_args.kwargs["username"] == "Custom Name"
|
||||
assert mock_token.call_args.kwargs["encryption_mode"] == encryption_mode
|
||||
|
||||
@@ -410,3 +410,24 @@ def test_api_rooms_update_livekit_sync_failure(mock_update_metadata, exception):
|
||||
"configuration": {"can_publish_sources": ["camera"]},
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"access_level", [RoomAccessLevel.PUBLIC, RoomAccessLevel.TRUSTED]
|
||||
)
|
||||
def test_api_rooms_update_encrypted_access_rejected(access_level):
|
||||
"""API updates cannot change an encrypted room away from restricted access."""
|
||||
room = RoomFactory(encryption_mode="basic")
|
||||
user = UserFactory()
|
||||
room.accesses.create(user=user, role="owner")
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.patch(
|
||||
f"/api/v1.0/rooms/{room.id}/", {"access_level": access_level}
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
assert "access_level" in response.json()
|
||||
room.refresh_from_db()
|
||||
assert room.access_level == RoomAccessLevel.RESTRICTED
|
||||
|
||||
@@ -303,6 +303,7 @@ def test_request_entry_public_room(
|
||||
configuration=room.configuration,
|
||||
participant_id="test-participant-id",
|
||||
role=None,
|
||||
encryption_mode="none",
|
||||
)
|
||||
|
||||
lobby_service._get_participant.assert_called_once_with(room.id, participant_id)
|
||||
@@ -342,6 +343,7 @@ def test_request_entry_trusted_room(
|
||||
configuration=room.configuration,
|
||||
participant_id="test-participant-id",
|
||||
role=None,
|
||||
encryption_mode="none",
|
||||
)
|
||||
|
||||
lobby_service._get_participant.assert_called_once_with(room.id, participant_id)
|
||||
@@ -374,7 +376,12 @@ def test_request_entry_new_participant(
|
||||
|
||||
assert participant == participant_data
|
||||
assert livekit_config is None
|
||||
mock_enter.assert_called_once_with(room.id, participant_id, username)
|
||||
mock_enter.assert_called_once_with(
|
||||
room.id,
|
||||
participant_id,
|
||||
username,
|
||||
is_authenticated=request.user.is_authenticated,
|
||||
)
|
||||
lobby_service._get_participant.assert_called_once_with(room.id, participant_id)
|
||||
|
||||
|
||||
@@ -442,6 +449,7 @@ def test_request_entry_accepted_participant(
|
||||
configuration=room.configuration,
|
||||
participant_id="test-participant-id",
|
||||
role=None,
|
||||
encryption_mode="none",
|
||||
)
|
||||
lobby_service._get_participant.assert_called_once_with(room.id, participant_id)
|
||||
|
||||
@@ -483,6 +491,7 @@ def test_request_entry_participant_with_role(
|
||||
configuration=room.configuration,
|
||||
participant_id="test-participant-id",
|
||||
role="administrator",
|
||||
encryption_mode="none",
|
||||
)
|
||||
lobby_service._get_participant.assert_called_once_with(room.id, participant_id)
|
||||
|
||||
@@ -886,6 +895,7 @@ def test_update_participant_status_success(mock_cache, lobby_service, participan
|
||||
"id": participant_id,
|
||||
"color": "#123456",
|
||||
"entered_at": "2025-01-01T10:00:00+00:00",
|
||||
"is_authenticated": False,
|
||||
}
|
||||
mock_cache.set.assert_called_once_with(
|
||||
"mocked_cache_key", expected_data, timeout=60
|
||||
|
||||
@@ -127,6 +127,7 @@ def test_api_users_retrieve_me_authenticated(settings):
|
||||
"short_name": user.short_name,
|
||||
"language": user.language,
|
||||
"timezone": "UTC",
|
||||
"default_encryption_mode": "none",
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -1,350 +0,0 @@
|
||||
"""Application hashing and migration of existing credentials."""
|
||||
|
||||
import hashlib
|
||||
from unittest import mock
|
||||
|
||||
from django.contrib.auth.hashers import check_password, identify_hasher, make_password
|
||||
from django.db import connection
|
||||
from django.test.utils import CaptureQueriesContext
|
||||
from django.utils.crypto import get_random_string
|
||||
|
||||
import pytest
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from core import hashers
|
||||
from core.factories import ApplicationFactory, UserFactory
|
||||
from core.models import Application
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
@pytest.mark.parametrize("secret", ["short", "a" * 128, b"byte-secret"])
|
||||
def test_application_hash(secret):
|
||||
"""Application hashes verify correctly but are not accepted for user passwords."""
|
||||
encoded = hashers.hash_client_secret(secret)
|
||||
raw = secret.encode() if isinstance(secret, str) else secret
|
||||
algorithm, version, digest = encoded.split("$")
|
||||
assert algorithm == "sha256"
|
||||
assert version == "v0"
|
||||
assert digest == hashlib.sha256(raw).hexdigest()
|
||||
assert hashers.hash_client_secret(secret) == encoded
|
||||
assert hashers.verify_client_secret(secret, encoded)
|
||||
assert not hashers.verify_client_secret("wrong", encoded)
|
||||
assert not hashers.verify_client_secret(None, encoded)
|
||||
assert not hashers.verify_client_secret(secret, "sha256$invalid")
|
||||
assert not hashers.verify_client_secret(secret, "sha256$v1$" + digest)
|
||||
assert not check_password(secret, encoded)
|
||||
with pytest.raises(ValueError):
|
||||
identify_hasher(encoded)
|
||||
assert not make_password(raw.decode()).startswith("sha256$")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("algorithm", ["pbkdf2_sha256", "md5"])
|
||||
def test_token_migrates_legacy_secret_once(algorithm):
|
||||
"""The same client secret works before and after migration, with no later writes."""
|
||||
secret = get_random_string(128)
|
||||
user = UserFactory()
|
||||
legacy = make_password(secret, hasher=algorithm)
|
||||
app = ApplicationFactory(client_secret=legacy)
|
||||
app.refresh_from_db()
|
||||
|
||||
assert app.client_secret == legacy
|
||||
assert app.client_secret_sha256 is None
|
||||
payload = {
|
||||
"client_id": app.client_id,
|
||||
"client_secret": secret,
|
||||
"grant_type": "client_credentials",
|
||||
"scope": user.email,
|
||||
}
|
||||
client = APIClient()
|
||||
response = client.post(
|
||||
"/external-api/v1.0/application/token/", payload, format="json"
|
||||
)
|
||||
assert response.status_code == 200
|
||||
app.refresh_from_db()
|
||||
migrated = app.client_secret_sha256
|
||||
assert check_password(secret, app.client_secret)
|
||||
assert hashers.CLIENT_SECRET_HASH_PATTERN.fullmatch(migrated)["digest"]
|
||||
assert hashers.verify_client_secret(secret, migrated)
|
||||
with CaptureQueriesContext(connection) as queries:
|
||||
response = client.post(
|
||||
"/external-api/v1.0/application/token/", payload, format="json"
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert not any(q["sql"].lstrip().startswith("UPDATE") for q in queries)
|
||||
app.refresh_from_db()
|
||||
assert app.client_secret_sha256 == migrated
|
||||
assert app.client_secret == legacy
|
||||
|
||||
|
||||
def test_wrong_secret_does_not_migrate():
|
||||
"""Failed authentication leaves a production PBKDF2 hash untouched."""
|
||||
user = UserFactory()
|
||||
legacy = make_password(get_random_string(128), hasher="pbkdf2_sha256")
|
||||
app = ApplicationFactory(client_secret=legacy)
|
||||
response = APIClient().post(
|
||||
"/external-api/v1.0/application/token/",
|
||||
{
|
||||
"client_id": app.client_id,
|
||||
"client_secret": "wrong",
|
||||
"grant_type": "client_credentials",
|
||||
"scope": user.email,
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
assert response.status_code == 401
|
||||
app.refresh_from_db()
|
||||
assert app.client_secret == legacy
|
||||
assert app.client_secret_sha256 is None
|
||||
|
||||
|
||||
def test_migration_preserves_concurrent_rotation():
|
||||
"""Migration must not restore a secret rotated after verification."""
|
||||
secret = get_random_string(128)
|
||||
app = ApplicationFactory(
|
||||
client_secret=make_password(secret, hasher="pbkdf2_sha256")
|
||||
)
|
||||
replacement = make_password(get_random_string(128), hasher="pbkdf2_sha256")
|
||||
|
||||
def verify_then_rotate(raw, encoded):
|
||||
verified = check_password(raw, encoded)
|
||||
Application.objects.filter(pk=app.pk).update(client_secret=replacement)
|
||||
return verified
|
||||
|
||||
with mock.patch.object(hashers, "check_password", side_effect=verify_then_rotate):
|
||||
assert app.check_client_secret(secret) is False
|
||||
|
||||
app.refresh_from_db()
|
||||
assert app.client_secret == replacement
|
||||
assert app.client_secret_sha256 is None
|
||||
|
||||
|
||||
def test_migration_preserves_concurrent_migration():
|
||||
"""Authentication succeeds when another request migrates the same secret."""
|
||||
secret = get_random_string(128)
|
||||
app = ApplicationFactory(
|
||||
client_secret=make_password(secret, hasher="pbkdf2_sha256")
|
||||
)
|
||||
migrated = hashers.hash_client_secret(secret)
|
||||
|
||||
def verify_then_migrate(raw, encoded):
|
||||
verified = check_password(raw, encoded)
|
||||
Application.objects.filter(pk=app.pk).update(client_secret_sha256=migrated)
|
||||
return verified
|
||||
|
||||
with mock.patch.object(hashers, "check_password", side_effect=verify_then_migrate):
|
||||
assert app.check_client_secret(secret) is True
|
||||
|
||||
app.refresh_from_db()
|
||||
assert app.client_secret_sha256 == migrated
|
||||
|
||||
|
||||
def test_migration_preserves_concurrent_deletion():
|
||||
"""Authentication fails when the application is deleted after verification."""
|
||||
secret = get_random_string(128)
|
||||
app = ApplicationFactory(
|
||||
client_secret=make_password(secret, hasher="pbkdf2_sha256")
|
||||
)
|
||||
|
||||
def verify_then_delete(raw, encoded):
|
||||
verified = check_password(raw, encoded)
|
||||
Application.objects.filter(pk=app.pk).delete()
|
||||
return verified
|
||||
|
||||
with mock.patch.object(hashers, "check_password", side_effect=verify_then_delete):
|
||||
assert app.check_client_secret(secret) is False
|
||||
|
||||
assert not Application.objects.filter(pk=app.pk).exists()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"secret",
|
||||
[
|
||||
"sha256$my-secret",
|
||||
"sha256$" + "a" * 63,
|
||||
"sha256$" + "a" * 64,
|
||||
"sha256$" + "g" * 64,
|
||||
"sha256$" + "a" * 64 + "\n",
|
||||
"sha256$$" + "a" * 64,
|
||||
"sha256$short$" + "a" * 64,
|
||||
"sha256$" + "b" * 22 + "$" + "g" * 64,
|
||||
"sha256$" + "b" * 22 + "$" + "a" * 64,
|
||||
"sha256$v0$" + "g" * 64,
|
||||
"sha256$v0$" + "a" * 63,
|
||||
"sha256$v1$" + "a" * 64,
|
||||
],
|
||||
)
|
||||
def test_prefixed_plaintext_is_hashed(secret):
|
||||
"""A prefix alone must not cause a raw secret to bypass hashing."""
|
||||
assert not hashers.CLIENT_SECRET_HASH_PATTERN.fullmatch(secret)
|
||||
app = ApplicationFactory(client_secret=secret)
|
||||
app.refresh_from_db()
|
||||
encoded = app.client_secret_sha256
|
||||
assert encoded != secret
|
||||
assert hashers.CLIENT_SECRET_HASH_PATTERN.fullmatch(encoded)["digest"]
|
||||
assert app.check_client_secret(secret)
|
||||
app.name = "Updated application"
|
||||
app.save()
|
||||
app.refresh_from_db()
|
||||
assert app.client_secret_sha256 == encoded
|
||||
|
||||
|
||||
def test_unsalted_secret_is_rejected():
|
||||
"""Only salted SHA-256 hashes are accepted."""
|
||||
secret = get_random_string(128)
|
||||
encoded = f"sha256${hashlib.sha256(secret.encode()).hexdigest()}"
|
||||
|
||||
assert hashers.CLIENT_SECRET_HASH_PATTERN.fullmatch(encoded) is None
|
||||
assert not hashers.verify_client_secret(secret, encoded)
|
||||
|
||||
|
||||
def test_new_application_supports_legacy_verification(settings):
|
||||
"""A rollback can authenticate applications created by the new release."""
|
||||
settings.PASSWORD_HASHERS = [
|
||||
"django.contrib.auth.hashers.PBKDF2PasswordHasher",
|
||||
]
|
||||
secret = get_random_string(128)
|
||||
app = ApplicationFactory(client_secret=secret)
|
||||
app.refresh_from_db()
|
||||
assert app.client_secret.startswith("pbkdf2_sha256$")
|
||||
assert check_password(secret, app.client_secret)
|
||||
assert hashers.verify_client_secret(secret, app.client_secret_sha256)
|
||||
with mock.patch.object(hashers, "check_password", side_effect=AssertionError):
|
||||
assert app.check_client_secret(secret)
|
||||
assert not app.check_client_secret("wrong")
|
||||
|
||||
|
||||
def test_unrelated_save_preserves_both_hashes():
|
||||
"""Saving an application's metadata does not change either credential hash."""
|
||||
app = ApplicationFactory()
|
||||
original = (app.client_secret, app.client_secret_sha256)
|
||||
app.name = "Renamed"
|
||||
app.save()
|
||||
app.refresh_from_db()
|
||||
assert (app.client_secret, app.client_secret_sha256) == original
|
||||
|
||||
|
||||
def test_creation_with_legacy_hash_defers_fast_hash_until_login():
|
||||
"""An imported Django hash is preserved, never treated as the raw secret."""
|
||||
secret = get_random_string(128)
|
||||
legacy = make_password(secret, hasher="pbkdf2_sha256")
|
||||
app = ApplicationFactory(client_secret=legacy)
|
||||
app.refresh_from_db()
|
||||
assert app.client_secret == legacy
|
||||
assert app.client_secret_sha256 is None
|
||||
assert not app.check_client_secret(legacy)
|
||||
assert app.check_client_secret(secret)
|
||||
app.refresh_from_db()
|
||||
assert app.client_secret == legacy
|
||||
assert hashers.verify_client_secret(secret, app.client_secret_sha256)
|
||||
|
||||
|
||||
def test_metadata_only_save_does_not_rotate_secret():
|
||||
"""A secret excluded from update_fields must not change either stored hash."""
|
||||
app = ApplicationFactory()
|
||||
original = (app.client_secret, app.client_secret_sha256)
|
||||
app.client_secret = get_random_string(128)
|
||||
app.name = "Renamed"
|
||||
app.save(update_fields=["name"])
|
||||
app.refresh_from_db()
|
||||
assert (app.client_secret, app.client_secret_sha256) == original
|
||||
|
||||
|
||||
def test_empty_update_fields_does_not_rotate_secret():
|
||||
"""Django's explicit no-op save must not update either credential field."""
|
||||
app = ApplicationFactory()
|
||||
original = (app.client_secret, app.client_secret_sha256)
|
||||
app.client_secret = get_random_string(128)
|
||||
with CaptureQueriesContext(connection) as queries:
|
||||
app.save(update_fields=[])
|
||||
assert not any(q["sql"].lstrip().startswith("UPDATE") for q in queries)
|
||||
app.refresh_from_db()
|
||||
assert (app.client_secret, app.client_secret_sha256) == original
|
||||
|
||||
|
||||
def test_creation_with_salted_hash_skips_fast_hash():
|
||||
"""An existing salted hash must not be hashed again as plaintext."""
|
||||
encoded = hashers.hash_client_secret(get_random_string(128))
|
||||
app = ApplicationFactory(client_secret=encoded)
|
||||
app.refresh_from_db()
|
||||
assert app.client_secret == encoded
|
||||
assert app.client_secret_sha256 is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize("legacy_only", [False, True])
|
||||
def test_rotate_client_secret_updates_both_hashes(legacy_only, settings):
|
||||
"""Rotation revokes the old secret for both current and rollback releases."""
|
||||
settings.PASSWORD_HASHERS = [
|
||||
"django.contrib.auth.hashers.PBKDF2PasswordHasher",
|
||||
]
|
||||
secret = get_random_string(128)
|
||||
app = ApplicationFactory(
|
||||
client_secret=make_password(secret) if legacy_only else secret
|
||||
)
|
||||
|
||||
replacement = app.rotate_client_secret()
|
||||
|
||||
assert replacement != secret
|
||||
assert len(replacement) == settings.APPLICATION_CLIENT_SECRET_LENGTH
|
||||
assert app.check_client_secret(replacement)
|
||||
assert not app.check_client_secret(secret)
|
||||
app.refresh_from_db()
|
||||
assert app.client_secret.startswith("pbkdf2_sha256$")
|
||||
assert check_password(replacement, app.client_secret)
|
||||
assert not check_password(secret, app.client_secret)
|
||||
assert hashers.verify_client_secret(replacement, app.client_secret_sha256)
|
||||
assert not app.check_client_secret(secret)
|
||||
assert app.client_secret != replacement
|
||||
assert app.client_secret_sha256 != replacement
|
||||
|
||||
|
||||
def test_rotate_client_secret_preserves_metadata():
|
||||
"""Rotation persists only the credential fields, not other pending changes."""
|
||||
app = ApplicationFactory()
|
||||
original_name = app.name
|
||||
original_client_id = app.client_id
|
||||
app.name = "Unsaved metadata"
|
||||
|
||||
app.rotate_client_secret()
|
||||
|
||||
app.refresh_from_db()
|
||||
assert app.name == original_name
|
||||
assert app.client_id == original_client_id
|
||||
|
||||
|
||||
def test_rotate_client_secret_repeatedly_revokes_previous_secrets():
|
||||
"""Only the latest generated secret remains valid after successive rotations."""
|
||||
original = get_random_string(128)
|
||||
app = ApplicationFactory(client_secret=original)
|
||||
first = app.rotate_client_secret()
|
||||
second = app.rotate_client_secret()
|
||||
|
||||
app.refresh_from_db()
|
||||
assert len({original, first, second}) == 3
|
||||
assert app.check_client_secret(second)
|
||||
assert check_password(second, app.client_secret)
|
||||
for revoked in (original, first):
|
||||
assert not app.check_client_secret(revoked)
|
||||
assert not check_password(revoked, app.client_secret)
|
||||
|
||||
|
||||
def test_token_endpoint_rejects_rotated_secret():
|
||||
"""New token requests reject the revoked secret and accept its replacement."""
|
||||
secret = get_random_string(128)
|
||||
app = ApplicationFactory(client_secret=secret)
|
||||
user = UserFactory()
|
||||
client = APIClient()
|
||||
payload = {
|
||||
"client_id": app.client_id,
|
||||
"client_secret": secret,
|
||||
"grant_type": "client_credentials",
|
||||
"scope": user.email,
|
||||
}
|
||||
endpoint = "/external-api/v1.0/application/token/"
|
||||
assert client.post(endpoint, payload, format="json").status_code == 200
|
||||
|
||||
replacement = app.rotate_client_secret()
|
||||
|
||||
assert client.post(endpoint, payload, format="json").status_code == 401
|
||||
payload["client_secret"] = replacement
|
||||
assert client.post(endpoint, payload, format="json").status_code == 200
|
||||
@@ -7,20 +7,17 @@ Tests for external API /token endpoint
|
||||
from unittest import mock
|
||||
from urllib.parse import urlencode
|
||||
|
||||
from django.contrib.auth.hashers import check_password
|
||||
|
||||
import jwt
|
||||
import pytest
|
||||
from freezegun import freeze_time
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from core import hashers
|
||||
from core.factories import (
|
||||
ApplicationDomainFactory,
|
||||
ApplicationFactory,
|
||||
UserFactory,
|
||||
)
|
||||
from core.models import Application, ApplicationScope, User
|
||||
from core.models import ApplicationScope, User
|
||||
from core.services import provisional_user_service
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
@@ -31,13 +28,15 @@ def test_api_applications_generate_token_application_disabled(settings):
|
||||
settings.APPLICATION_ENABLED = False
|
||||
|
||||
user = UserFactory(email="user@example.com")
|
||||
plain_secret = "test-secret-123"
|
||||
application = ApplicationFactory(
|
||||
client_secret=plain_secret,
|
||||
is_active=True,
|
||||
scopes=[ApplicationScope.ROOMS_LIST],
|
||||
)
|
||||
|
||||
plain_secret = "test-secret-123"
|
||||
application.client_secret = plain_secret
|
||||
application.save()
|
||||
|
||||
client = APIClient()
|
||||
response = client.post(
|
||||
"/external-api/v1.0/application/token/",
|
||||
@@ -56,13 +55,16 @@ def test_api_applications_generate_token_application_disabled(settings):
|
||||
def test_api_applications_generate_token_success(settings):
|
||||
"""Valid credentials should return a JWT token."""
|
||||
UserFactory(email="User.Family@example.com")
|
||||
plain_secret = "test-secret-123"
|
||||
application = ApplicationFactory(
|
||||
client_secret=plain_secret,
|
||||
is_active=True,
|
||||
scopes=[ApplicationScope.ROOMS_LIST, ApplicationScope.ROOMS_CREATE],
|
||||
)
|
||||
|
||||
# Store plain secret before it's hashed
|
||||
plain_secret = "test-secret-123"
|
||||
application.client_secret = plain_secret
|
||||
application.save()
|
||||
|
||||
client = APIClient()
|
||||
response = client.post(
|
||||
"/external-api/v1.0/application/token/",
|
||||
@@ -93,13 +95,15 @@ def test_api_applications_generate_token_form_urlencoded(settings):
|
||||
token endpoints, so that standard OAuth 2.0 client libraries work
|
||||
out of the box."""
|
||||
UserFactory(email="user@example.com")
|
||||
plain_secret = "test-secret-123"
|
||||
application = ApplicationFactory(
|
||||
client_secret=plain_secret,
|
||||
is_active=True,
|
||||
scopes=[ApplicationScope.ROOMS_LIST, ApplicationScope.ROOMS_CREATE],
|
||||
)
|
||||
|
||||
plain_secret = "test-secret-123"
|
||||
application.client_secret = plain_secret
|
||||
application.save()
|
||||
|
||||
client = APIClient()
|
||||
response = client.post(
|
||||
"/external-api/v1.0/application/token/",
|
||||
@@ -166,8 +170,11 @@ def test_api_applications_generate_token_form_urlencoded_missing_fields():
|
||||
def test_api_applications_generate_token_form_urlencoded_invalid_grant_type():
|
||||
"""An unsupported grant_type sent as form-urlencoded should return 400."""
|
||||
user = UserFactory(email="user@example.com")
|
||||
application = ApplicationFactory(is_active=True)
|
||||
|
||||
plain_secret = "test-secret-123"
|
||||
application = ApplicationFactory(client_secret=plain_secret, is_active=True)
|
||||
application.client_secret = plain_secret
|
||||
application.save()
|
||||
|
||||
client = APIClient()
|
||||
response = client.post(
|
||||
@@ -191,13 +198,15 @@ def test_api_applications_generate_token_form_urlencoded_special_characters():
|
||||
"""Percent-encoded reserved characters ("&", "=", "+", "%") in the
|
||||
client_secret should survive form-urlencoded decoding."""
|
||||
UserFactory(email="user@example.com")
|
||||
plain_secret = "s3cr3t&with=special+chars%42"
|
||||
application = ApplicationFactory(
|
||||
client_secret=plain_secret,
|
||||
is_active=True,
|
||||
scopes=[ApplicationScope.ROOMS_LIST],
|
||||
)
|
||||
|
||||
plain_secret = "s3cr3t&with=special+chars%42"
|
||||
application.client_secret = plain_secret
|
||||
application.save()
|
||||
|
||||
client = APIClient()
|
||||
response = client.post(
|
||||
"/external-api/v1.0/application/token/",
|
||||
@@ -270,54 +279,14 @@ def test_api_applications_generate_token_invalid_client_secret():
|
||||
assert "Invalid credentials" in str(response.data)
|
||||
|
||||
|
||||
def test_token_unknown_client_id_with_valid_secret():
|
||||
"""A valid secret cannot authenticate an unknown client ID."""
|
||||
secret = "application-a-secret"
|
||||
ApplicationFactory(client_secret=secret)
|
||||
user = UserFactory()
|
||||
|
||||
response = APIClient().post(
|
||||
"/external-api/v1.0/application/token/",
|
||||
{
|
||||
"client_id": "unknown-client-id",
|
||||
"client_secret": secret,
|
||||
"grant_type": "client_credentials",
|
||||
"scope": user.email,
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 401
|
||||
assert "Invalid credentials" in str(response.data)
|
||||
|
||||
|
||||
def test_token_rejects_secret_owned_by_another_application():
|
||||
"""Application A's secret cannot authenticate application B."""
|
||||
secret_a = "application-a-secret"
|
||||
ApplicationFactory(client_secret=secret_a)
|
||||
application_b = ApplicationFactory(client_secret="application-b-secret")
|
||||
user = UserFactory()
|
||||
|
||||
response = APIClient().post(
|
||||
"/external-api/v1.0/application/token/",
|
||||
{
|
||||
"client_id": application_b.client_id,
|
||||
"client_secret": secret_a,
|
||||
"grant_type": "client_credentials",
|
||||
"scope": user.email,
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 401
|
||||
assert "Invalid credentials" in str(response.data)
|
||||
|
||||
|
||||
def test_api_applications_generate_token_inactive_application():
|
||||
"""Inactive application should return 401."""
|
||||
user = UserFactory(email="user@example.com")
|
||||
application = ApplicationFactory(is_active=False)
|
||||
|
||||
plain_secret = "test-secret-123"
|
||||
application = ApplicationFactory(client_secret=plain_secret, is_active=False)
|
||||
application.client_secret = plain_secret
|
||||
application.save()
|
||||
|
||||
client = APIClient()
|
||||
response = client.post(
|
||||
@@ -359,8 +328,11 @@ def test_api_applications_generate_token_inactive_application_wrong_secret():
|
||||
|
||||
def test_api_applications_generate_token_invalid_email_format():
|
||||
"""Invalid email format should return 400."""
|
||||
application = ApplicationFactory(is_active=True)
|
||||
|
||||
plain_secret = "test-secret-123"
|
||||
application = ApplicationFactory(client_secret=plain_secret, is_active=True)
|
||||
application.client_secret = plain_secret
|
||||
application.save()
|
||||
|
||||
client = APIClient()
|
||||
response = client.post(
|
||||
@@ -381,10 +353,13 @@ def test_api_applications_generate_token_invalid_email_format():
|
||||
def test_api_applications_generate_token_domain_not_authorized():
|
||||
"""Application without domain authorization should return 403."""
|
||||
user = UserFactory(email="user@denied.com")
|
||||
plain_secret = "test-secret-123"
|
||||
application = ApplicationFactory(client_secret=plain_secret, is_active=True)
|
||||
application = ApplicationFactory(is_active=True)
|
||||
ApplicationDomainFactory(application=application, domain="allowed.com")
|
||||
|
||||
plain_secret = "test-secret-123"
|
||||
application.client_secret = plain_secret
|
||||
application.save()
|
||||
|
||||
client = APIClient()
|
||||
response = client.post(
|
||||
"/external-api/v1.0/application/token/",
|
||||
@@ -404,14 +379,16 @@ def test_api_applications_generate_token_domain_not_authorized():
|
||||
def test_api_applications_generate_token_domain_authorized():
|
||||
"""Application with domain authorization should succeed."""
|
||||
user = UserFactory(email="user@allowed.com")
|
||||
plain_secret = "test-secret-123"
|
||||
application = ApplicationFactory(
|
||||
client_secret=plain_secret,
|
||||
is_active=True,
|
||||
scopes=[ApplicationScope.ROOMS_LIST],
|
||||
)
|
||||
ApplicationDomainFactory(application=application, domain="allowed.com")
|
||||
|
||||
plain_secret = "test-secret-123"
|
||||
application.client_secret = plain_secret
|
||||
application.save()
|
||||
|
||||
client = APIClient()
|
||||
response = client.post(
|
||||
"/external-api/v1.0/application/token/",
|
||||
@@ -430,8 +407,11 @@ def test_api_applications_generate_token_domain_authorized():
|
||||
|
||||
def test_api_applications_generate_token_user_not_found():
|
||||
"""Non-existent user should return 404."""
|
||||
application = ApplicationFactory(is_active=True)
|
||||
|
||||
plain_secret = "test-secret-123"
|
||||
application = ApplicationFactory(client_secret=plain_secret, is_active=True)
|
||||
application.client_secret = plain_secret
|
||||
application.save()
|
||||
|
||||
client = APIClient()
|
||||
response = client.post(
|
||||
@@ -454,13 +434,15 @@ def test_api_applications_token_payload_structure(settings):
|
||||
"""Generated token should have correct payload structure."""
|
||||
user = UserFactory(email="user@example.com")
|
||||
|
||||
plain_secret = "test-secret-123"
|
||||
application = ApplicationFactory(
|
||||
client_secret=plain_secret,
|
||||
is_active=True,
|
||||
scopes=[ApplicationScope.ROOMS_LIST, ApplicationScope.ROOMS_CREATE],
|
||||
)
|
||||
|
||||
plain_secret = "test-secret-123"
|
||||
application.client_secret = plain_secret
|
||||
application.save()
|
||||
|
||||
client = APIClient()
|
||||
response = client.post(
|
||||
"/external-api/v1.0/application/token/",
|
||||
@@ -505,13 +487,15 @@ def test_api_applications_token_new_user(settings):
|
||||
|
||||
assert len(User.objects.all()) == 0
|
||||
|
||||
plain_secret = "test-secret-123"
|
||||
application = ApplicationFactory(
|
||||
client_secret=plain_secret,
|
||||
is_active=True,
|
||||
scopes=[ApplicationScope.ROOMS_LIST, ApplicationScope.ROOMS_CREATE],
|
||||
)
|
||||
|
||||
plain_secret = "test-secret-123"
|
||||
application.client_secret = plain_secret
|
||||
application.save()
|
||||
|
||||
client = APIClient()
|
||||
response = client.post(
|
||||
"/external-api/v1.0/application/token/",
|
||||
@@ -561,13 +545,15 @@ def test_api_applications_token_existing_user(settings):
|
||||
|
||||
assert len(User.objects.all()) == 1
|
||||
|
||||
plain_secret = "test-secret-123"
|
||||
application = ApplicationFactory(
|
||||
client_secret=plain_secret,
|
||||
is_active=True,
|
||||
scopes=[ApplicationScope.ROOMS_LIST, ApplicationScope.ROOMS_CREATE],
|
||||
)
|
||||
|
||||
plain_secret = "test-secret-123"
|
||||
application.client_secret = plain_secret
|
||||
application.save()
|
||||
|
||||
client = APIClient()
|
||||
response = client.post(
|
||||
"/external-api/v1.0/application/token/",
|
||||
@@ -612,10 +598,12 @@ def test_api_applications_token_new_user_race_condition(mock_get_by_email, setti
|
||||
settings.OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION = True
|
||||
settings.OIDC_USER_SUB_FIELD_IMMUTABLE = False
|
||||
|
||||
plain_secret = "test-secret-123"
|
||||
application = ApplicationFactory(
|
||||
client_secret=plain_secret, is_active=True, scopes=[ApplicationScope.ROOMS_LIST]
|
||||
is_active=True, scopes=[ApplicationScope.ROOMS_LIST]
|
||||
)
|
||||
plain_secret = "test-secret-123"
|
||||
application.client_secret = plain_secret
|
||||
application.save()
|
||||
|
||||
email = "john.doe@example.com"
|
||||
|
||||
@@ -665,10 +653,12 @@ def test_api_applications_token_new_user_race_condition_unrecoverable(
|
||||
settings.OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION = True
|
||||
settings.OIDC_USER_SUB_FIELD_IMMUTABLE = False
|
||||
|
||||
plain_secret = "test-secret-123"
|
||||
application = ApplicationFactory(
|
||||
client_secret=plain_secret, is_active=True, scopes=[ApplicationScope.ROOMS_LIST]
|
||||
is_active=True, scopes=[ApplicationScope.ROOMS_LIST]
|
||||
)
|
||||
plain_secret = "test-secret-123"
|
||||
application.client_secret = plain_secret
|
||||
application.save()
|
||||
|
||||
client = APIClient()
|
||||
response = client.post(
|
||||
@@ -684,183 +674,3 @@ def test_api_applications_token_new_user_race_condition_unrecoverable(
|
||||
|
||||
assert response.status_code == 409
|
||||
assert mock_get_or_create.call_count == 1
|
||||
|
||||
|
||||
def test_token_populates_fast_hash_and_stops_using_legacy_hash():
|
||||
"""First login migrates; subsequent logins use only the fast hash."""
|
||||
secret = "application-secret"
|
||||
|
||||
application = ApplicationFactory(client_secret=secret)
|
||||
Application.objects.filter(pk=application.pk).update(client_secret_sha256=None)
|
||||
application.refresh_from_db()
|
||||
|
||||
original_hash = application.client_secret
|
||||
|
||||
user = UserFactory()
|
||||
payload = {
|
||||
"client_id": application.client_id,
|
||||
"client_secret": secret,
|
||||
"grant_type": "client_credentials",
|
||||
"scope": user.email,
|
||||
}
|
||||
client = APIClient()
|
||||
|
||||
with mock.patch.object(
|
||||
hashers, "check_password", wraps=hashers.check_password
|
||||
) as legacy_verifier:
|
||||
response = client.post(
|
||||
"/external-api/v1.0/application/token/", payload, format="json"
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
legacy_verifier.assert_called_once_with(secret, original_hash)
|
||||
|
||||
application.refresh_from_db()
|
||||
|
||||
migrated_hash = application.client_secret_sha256
|
||||
assert hashers.CLIENT_SECRET_HASH_PATTERN.fullmatch(migrated_hash)
|
||||
assert hashers.verify_client_secret(secret, migrated_hash)
|
||||
assert application.client_secret == original_hash
|
||||
|
||||
# Fail immediately if a subsequent login tries the legacy verifier.
|
||||
with mock.patch.object(
|
||||
hashers,
|
||||
"check_password",
|
||||
side_effect=AssertionError("Legacy hash must no longer be used"),
|
||||
):
|
||||
response = client.post(
|
||||
"/external-api/v1.0/application/token/", payload, format="json"
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
application.refresh_from_db()
|
||||
assert application.client_secret_sha256 == migrated_hash
|
||||
assert application.client_secret == original_hash
|
||||
|
||||
|
||||
def test_token_failed_login_leaves_legacy_credentials_untouched():
|
||||
"""An incorrect secret neither migrates nor changes the legacy hash."""
|
||||
|
||||
application = ApplicationFactory(client_secret="application-secret")
|
||||
Application.objects.filter(pk=application.pk).update(client_secret_sha256=None)
|
||||
application.refresh_from_db()
|
||||
|
||||
original_hash = application.client_secret
|
||||
user = UserFactory()
|
||||
|
||||
response = APIClient().post(
|
||||
"/external-api/v1.0/application/token/",
|
||||
{
|
||||
"client_id": application.client_id,
|
||||
"client_secret": "wrong-secret",
|
||||
"grant_type": "client_credentials",
|
||||
"scope": user.email,
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 401
|
||||
application.refresh_from_db()
|
||||
assert application.client_secret == original_hash
|
||||
assert application.client_secret_sha256 is None
|
||||
|
||||
|
||||
def test_token_concurrent_successful_logins_preserve_first_migration():
|
||||
"""Both logins succeed; the later migration preserves the first hash."""
|
||||
secret = "application-secret"
|
||||
application = ApplicationFactory(client_secret=secret)
|
||||
Application.objects.filter(pk=application.pk).update(client_secret_sha256=None)
|
||||
application.refresh_from_db()
|
||||
|
||||
original_hash = application.client_secret
|
||||
user = UserFactory()
|
||||
payload = {
|
||||
"client_id": application.client_id,
|
||||
"client_secret": secret,
|
||||
"grant_type": "client_credentials",
|
||||
"scope": user.email,
|
||||
}
|
||||
legacy_verifier = hashers.check_password
|
||||
winning_hashes = []
|
||||
|
||||
def verify_then_complete_other_login(raw_secret, encoded):
|
||||
verified = legacy_verifier(raw_secret, encoded)
|
||||
|
||||
# Complete another login before this request writes its migration.
|
||||
# Restore the real verifier to avoid recursively invoking this callback.
|
||||
with mock.patch.object(hashers, "check_password", new=legacy_verifier):
|
||||
other_response = APIClient().post(
|
||||
"/external-api/v1.0/application/token/", payload, format="json"
|
||||
)
|
||||
|
||||
assert other_response.status_code == 200
|
||||
application.refresh_from_db()
|
||||
winning_hashes.append(application.client_secret_sha256)
|
||||
return verified
|
||||
|
||||
with mock.patch.object(
|
||||
hashers, "check_password", side_effect=verify_then_complete_other_login
|
||||
) as verifier:
|
||||
response = APIClient().post(
|
||||
"/external-api/v1.0/application/token/", payload, format="json"
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
verifier.assert_called_once_with(secret, original_hash)
|
||||
application.refresh_from_db()
|
||||
assert application.client_secret_sha256 == winning_hashes[0]
|
||||
assert hashers.verify_client_secret(secret, application.client_secret_sha256)
|
||||
assert application.client_secret == original_hash
|
||||
|
||||
|
||||
def test_token_authenticates_after_rollback():
|
||||
"""Legacy authentication still works after the fast hash is discarded."""
|
||||
secret = "application-secret"
|
||||
application = ApplicationFactory(client_secret=secret)
|
||||
Application.objects.filter(pk=application.pk).update(client_secret_sha256=None)
|
||||
application.refresh_from_db()
|
||||
|
||||
original_hash = application.client_secret
|
||||
|
||||
user = UserFactory()
|
||||
payload = {
|
||||
"client_id": application.client_id,
|
||||
"client_secret": secret,
|
||||
"grant_type": "client_credentials",
|
||||
"scope": user.email,
|
||||
}
|
||||
client = APIClient()
|
||||
|
||||
# Authenticate with the new implementation and migrate the hash.
|
||||
response = client.post(
|
||||
"/external-api/v1.0/application/token/", payload, format="json"
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
application.refresh_from_db()
|
||||
|
||||
assert hashers.verify_client_secret(secret, application.client_secret_sha256)
|
||||
assert application.client_secret == original_hash
|
||||
|
||||
Application.objects.filter(pk=application.pk).update(client_secret_sha256=None)
|
||||
|
||||
def legacy_check(instance, raw_secret):
|
||||
return check_password(raw_secret, instance.client_secret)
|
||||
|
||||
# Simulate the old release's verification using only the legacy field.
|
||||
with mock.patch.object(
|
||||
Application,
|
||||
"check_client_secret",
|
||||
autospec=True,
|
||||
side_effect=legacy_check,
|
||||
) as verifier:
|
||||
response = client.post(
|
||||
"/external-api/v1.0/application/token/", payload, format="json"
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
verifier.assert_called_once()
|
||||
application.refresh_from_db()
|
||||
assert application.client_secret == original_hash
|
||||
assert application.client_secret_sha256 is None
|
||||
|
||||
@@ -6,12 +6,12 @@ Unit tests for the Application and ApplicationDomain models
|
||||
|
||||
from unittest import mock
|
||||
|
||||
from django.contrib.auth.hashers import check_password
|
||||
from django.core.exceptions import ValidationError
|
||||
|
||||
import pytest
|
||||
|
||||
from core.factories import ApplicationDomainFactory, ApplicationFactory
|
||||
from core.hashers import verify_client_secret
|
||||
from core.models import Application, ApplicationDomain, ApplicationScope
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
@@ -98,8 +98,8 @@ def test_models_application_client_secret_hashed_on_save():
|
||||
|
||||
# Secret should be hashed, not plain
|
||||
assert application.client_secret != plain_secret
|
||||
# Should verify with the application credential policy
|
||||
assert verify_client_secret(plain_secret, application.client_secret) is True
|
||||
# Should verify with check_password
|
||||
assert check_password(plain_secret, application.client_secret) is True
|
||||
|
||||
|
||||
def test_models_application_client_secret_preserves_existing_hash():
|
||||
|
||||
@@ -360,3 +360,35 @@ def test_pin_generation_upper_bound(mock_randbelow, settings):
|
||||
|
||||
# Assert called with the right exclusive upper bound, 10^5
|
||||
mock_randbelow.assert_called_with(100000)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("access_level", [None, *RoomAccessLevel.values])
|
||||
@pytest.mark.parametrize("use_manager", [False, True])
|
||||
def test_models_encrypted_room_creation_is_restricted(access_level, use_manager):
|
||||
"""Both save and manager creation normalize encrypted rooms before validation."""
|
||||
fields = {"name": "Encrypted room", "encryption_mode": "basic"}
|
||||
if access_level is not None:
|
||||
fields["access_level"] = access_level
|
||||
if use_manager:
|
||||
room = Room.objects.create(**fields)
|
||||
else:
|
||||
room = Room(**fields)
|
||||
# A caller may assign the primary key before the first save.
|
||||
room.pk = room.id
|
||||
room.save()
|
||||
room.refresh_from_db()
|
||||
assert room.access_level == RoomAccessLevel.RESTRICTED
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"access_level", [RoomAccessLevel.PUBLIC, RoomAccessLevel.TRUSTED]
|
||||
)
|
||||
def test_models_encrypted_room_access_update_rejected(access_level):
|
||||
"""Existing encrypted rooms reject incompatible access instead of normalizing it."""
|
||||
room = Room.objects.create(name="Encrypted room", encryption_mode="basic")
|
||||
room.access_level = access_level
|
||||
with pytest.raises(ValidationError) as excinfo:
|
||||
room.save()
|
||||
assert "access_level" in excinfo.value.message_dict
|
||||
room.refresh_from_db()
|
||||
assert room.access_level == RoomAccessLevel.RESTRICTED
|
||||
|
||||
@@ -57,21 +57,35 @@ def test_generate_token_authenticated_fallback_user_representation():
|
||||
assert claims["name"] == str(user)
|
||||
|
||||
|
||||
def test_generate_token_explicit_username_overrides_default():
|
||||
@pytest.mark.parametrize("encryption_mode", ["none", "basic"])
|
||||
def test_generate_token_explicit_username_overrides_default(encryption_mode):
|
||||
"""An explicitly provided username should take precedence over the full name."""
|
||||
user = UserFactory(full_name="Jane Doe")
|
||||
|
||||
token = generate_token(room="my-room", user=user, username="Custom Name")
|
||||
token = generate_token(
|
||||
room="my-room",
|
||||
user=user,
|
||||
username="Custom Name",
|
||||
encryption_mode=encryption_mode,
|
||||
)
|
||||
|
||||
claims = decode_token(token)
|
||||
assert claims["name"] == "Custom Name"
|
||||
|
||||
|
||||
def test_authenticated_username_ignored_when_editing_disabled(settings):
|
||||
@pytest.mark.parametrize("encryption_mode", ["none", "basic"])
|
||||
def test_authenticated_username_ignored_when_editing_disabled(
|
||||
settings, encryption_mode
|
||||
):
|
||||
"""With editing disabled, an authenticated user's username is ignored."""
|
||||
settings.AUTHENTICATED_PARTICIPANTS_CAN_EDIT_DISPLAY_NAME = False
|
||||
user = UserFactory(full_name="Jane Doe")
|
||||
token = generate_token(room="my-room", user=user, username="Custom Name")
|
||||
token = generate_token(
|
||||
room="my-room",
|
||||
user=user,
|
||||
username="Custom Name",
|
||||
encryption_mode=encryption_mode,
|
||||
)
|
||||
claims = decode_token(token)
|
||||
assert claims["name"] == "Jane Doe"
|
||||
|
||||
|
||||
@@ -34,6 +34,9 @@ from livekit.api import ( # pylint: disable=E0611
|
||||
TwirpError,
|
||||
VideoGrants,
|
||||
)
|
||||
from livekit.protocol.room import RoomConfiguration # pylint: disable=E0611
|
||||
|
||||
from core.enums import EncryptionMode
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -69,6 +72,7 @@ def generate_token( # noqa: PLR0917
|
||||
role: Optional[str] = None,
|
||||
participant_id: Optional[str] = None,
|
||||
ttl: Optional[timedelta] = None,
|
||||
encryption_mode: str = "none",
|
||||
) -> str:
|
||||
"""Generate a LiveKit access token for a user in a specific room.
|
||||
|
||||
@@ -91,10 +95,8 @@ def generate_token( # noqa: PLR0917
|
||||
"""
|
||||
|
||||
is_admin_or_owner = role in ("owner", "administrator")
|
||||
if is_admin_or_owner:
|
||||
sources = settings.LIVEKIT_DEFAULT_SOURCES
|
||||
|
||||
if sources is None:
|
||||
if is_admin_or_owner or sources is None:
|
||||
sources = settings.LIVEKIT_DEFAULT_SOURCES
|
||||
|
||||
video_grants = VideoGrants(
|
||||
@@ -141,6 +143,14 @@ def generate_token( # noqa: PLR0917
|
||||
if ttl is not None:
|
||||
token = token.with_ttl(ttl)
|
||||
|
||||
if encryption_mode != EncryptionMode.NONE:
|
||||
token = token.with_room_config(
|
||||
RoomConfiguration(
|
||||
name=room,
|
||||
metadata=json.dumps({"encryption_mode": encryption_mode}),
|
||||
)
|
||||
)
|
||||
|
||||
return token.to_jwt()
|
||||
|
||||
|
||||
@@ -152,6 +162,7 @@ def generate_livekit_config( # noqa: PLR0917
|
||||
color: Optional[str] = None,
|
||||
configuration: Optional[dict] = None,
|
||||
participant_id: Optional[str] = None,
|
||||
encryption_mode: str = "none",
|
||||
) -> dict:
|
||||
"""Generate LiveKit configuration for room access.
|
||||
|
||||
@@ -184,6 +195,7 @@ def generate_livekit_config( # noqa: PLR0917
|
||||
sources=sources,
|
||||
role=role,
|
||||
participant_id=participant_id,
|
||||
encryption_mode=encryption_mode,
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
@@ -978,6 +978,10 @@ class Base(Configuration):
|
||||
environ_prefix=None,
|
||||
)
|
||||
|
||||
ENCRYPTION_ENABLED = values.BooleanValue(
|
||||
False, environ_name="ENCRYPTION_ENABLED", environ_prefix=None
|
||||
)
|
||||
|
||||
# External Applications
|
||||
APPLICATION_ENABLED = values.BooleanValue(
|
||||
False, environ_name="APPLICATION_ENABLED", environ_prefix=None
|
||||
@@ -988,7 +992,7 @@ class Base(Configuration):
|
||||
environ_prefix=None,
|
||||
)
|
||||
APPLICATION_CLIENT_SECRET_LENGTH = values.PositiveIntegerValue(
|
||||
50,
|
||||
128,
|
||||
environ_name="APPLICATION_CLIENT_SECRET_LENGTH",
|
||||
environ_prefix=None,
|
||||
)
|
||||
@@ -1249,20 +1253,6 @@ class Base(Configuration):
|
||||
stacklevel=2,
|
||||
)
|
||||
|
||||
# Secrets use a 62-character alphanumeric charset (~5.95 bits/char).
|
||||
# 43 characters provide at least 256 bits of entropy; 42 provide ~250 bits.
|
||||
if cls.APPLICATION_CLIENT_SECRET_LENGTH < 43:
|
||||
warnings.warn(
|
||||
f"APPLICATION_CLIENT_SECRET_LENGTH={cls.APPLICATION_CLIENT_SECRET_LENGTH} "
|
||||
"is below the recommended 43 characters (256 bits of entropy). "
|
||||
"Application secrets use a fast hash and rely on high entropy to "
|
||||
"resist offline guessing if the database leaks. "
|
||||
"Please set APPLICATION_CLIENT_SECRET_LENGTH to at least 43.",
|
||||
# We use UserWarning to make sure it shows up in production deployment
|
||||
UserWarning,
|
||||
stacklevel=2,
|
||||
)
|
||||
|
||||
# The SENTRY_DSN setting should be available to activate sentry for an environment
|
||||
if cls.SENTRY_DSN is not None:
|
||||
sentry_sdk.init(
|
||||
@@ -1348,7 +1338,6 @@ class Test(Base):
|
||||
)
|
||||
PASSWORD_HASHERS = [
|
||||
"django.contrib.auth.hashers.MD5PasswordHasher",
|
||||
"django.contrib.auth.hashers.PBKDF2PasswordHasher",
|
||||
]
|
||||
USE_SWAGGER = True
|
||||
EXTERNAL_API_ENABLED = True
|
||||
|
||||
@@ -7,7 +7,7 @@ build-backend = "uv_build"
|
||||
|
||||
[project]
|
||||
name = "meet"
|
||||
version = "1.32.1"
|
||||
version = "1.31.0"
|
||||
authors = [{ "name" = "DINUM", "email" = "dev@mail.numerique.gouv.fr" }]
|
||||
classifiers = [
|
||||
"Development Status :: 5 - Production/Stable",
|
||||
@@ -41,7 +41,7 @@ dependencies = [
|
||||
"django-timezone-field>=5.1",
|
||||
"django-pydantic-field==0.5.4",
|
||||
"django==5.2.16",
|
||||
"djangorestframework==3.17.2",
|
||||
"djangorestframework==3.17.1",
|
||||
"drf_spectacular==0.30.0",
|
||||
"dockerflow==2026.3.4",
|
||||
"easy_thumbnails==2.10.1",
|
||||
|
||||
Generated
+5
-5
@@ -754,14 +754,14 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "djangorestframework"
|
||||
version = "3.17.2"
|
||||
version = "3.17.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "django" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/3b/35/c96055e700fdff25da3a7b7756cfd1d4dc54f38b9bc6d6c5e19e3a0fdc20/djangorestframework-3.17.2.tar.gz", hash = "sha256:89ed713b6dc83e1539f214b7d10808ae19bb8511004beba886225da6d5c9dafa", size = 906683, upload-time = "2026-08-05T07:47:22.5Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/ca/d7/c016e69fac19ff8afdc89db9d31d9ae43ae031e4d1993b20aca179b8301a/djangorestframework-3.17.1.tar.gz", hash = "sha256:a6def5f447fe78ff853bff1d47a3c59bf38f5434b031780b351b0c73a62db1a5", size = 905742, upload-time = "2026-03-24T16:58:33.705Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/a2/46/c14108e400b208c394325eb63fbae06c81341b6447fa1a6f9da718b17fe7/djangorestframework-3.17.2-py3-none-any.whl", hash = "sha256:cb0546a7415d5b46c04e0f4fe0a54b2109f4fdd5e83ca773c8c6183a6493d042", size = 899109, upload-time = "2026-08-05T07:47:20.853Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5a/e1/2c516bdc83652b1a60c6119366ac2c0607b479ed05cd6093f916ca8928f8/djangorestframework-3.17.1-py3-none-any.whl", hash = "sha256:c3c74dd3e83a5a3efc37b3c18d92bd6f86a6791c7b7d4dff62bb068500e76457", size = 898844, upload-time = "2026-03-24T16:58:31.845Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1187,7 +1187,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "meet"
|
||||
version = "1.32.1"
|
||||
version = "1.31.0"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "aiohttp" },
|
||||
@@ -1273,7 +1273,7 @@ requires-dist = [
|
||||
{ name = "django-redis", specifier = "==7.0.0" },
|
||||
{ name = "django-storages", extras = ["s3"], specifier = "==1.14.6" },
|
||||
{ name = "django-timezone-field", specifier = ">=5.1" },
|
||||
{ name = "djangorestframework", specifier = "==3.17.2" },
|
||||
{ name = "djangorestframework", specifier = "==3.17.1" },
|
||||
{ name = "dockerflow", specifier = "==2026.3.4" },
|
||||
{ name = "drf-spectacular", specifier = "==0.30.0" },
|
||||
{ name = "easy-thumbnails", specifier = "==2.10.1" },
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "meet",
|
||||
"version": "1.32.1",
|
||||
"version": "1.31.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "meet",
|
||||
"version": "1.32.1",
|
||||
"version": "1.31.0",
|
||||
"dependencies": {
|
||||
"@fontsource-variable/atkinson-hyperlegible-next": "5.3.0",
|
||||
"@fontsource-variable/lexend": "5.3.0",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "meet",
|
||||
"private": true,
|
||||
"version": "1.32.1",
|
||||
"version": "1.31.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "panda codegen && vite",
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "mail_mjml",
|
||||
"version": "1.32.1",
|
||||
"version": "1.31.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "mail_mjml",
|
||||
"version": "1.32.1",
|
||||
"version": "1.31.0",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@html-to/text-cli": "0.6.1",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "mail_mjml",
|
||||
"version": "1.32.1",
|
||||
"version": "1.31.0",
|
||||
"description": "An util to generate html and text django's templates from mjml templates",
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "sdk",
|
||||
"version": "1.32.1",
|
||||
"version": "1.31.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "sdk",
|
||||
"version": "1.32.1",
|
||||
"version": "1.31.0",
|
||||
"license": "ISC",
|
||||
"workspaces": [
|
||||
"./library",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "sdk",
|
||||
"version": "1.32.1",
|
||||
"version": "1.31.0",
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"description": "",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
|
||||
[project]
|
||||
name = "summary"
|
||||
version = "1.32.1"
|
||||
version = "1.31.0"
|
||||
requires-python = ">=3.13"
|
||||
dependencies = [
|
||||
"fastapi[standard]>=0.105.0",
|
||||
|
||||
Generated
+1
-1
@@ -1516,7 +1516,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "summary"
|
||||
version = "1.32.1"
|
||||
version = "1.31.0"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "celery" },
|
||||
|
||||
Reference in New Issue
Block a user