mirror of
https://github.com/suitenumerique/meet.git
synced 2026-10-07 22:10:56 +00:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3ec6c43c4d |
@@ -10,20 +10,6 @@ and this project adheres to
|
||||
|
||||
### Added
|
||||
|
||||
- ⚡️(backend) add UPPER(email) index for case-insensitive user lookups
|
||||
|
||||
### Changed
|
||||
|
||||
- ⚡️(backend) hash application secrets with SHA-256
|
||||
|
||||
### Fixed
|
||||
|
||||
- 🔒️(backend) prevent editing client id and secret in Django admin
|
||||
|
||||
## [1.34.0] - 2026-10-07
|
||||
|
||||
### Added
|
||||
|
||||
- ✨(helm) import environment variables from Secrets and ConfigMaps
|
||||
- 🔒(backend) throttle meeting link generation
|
||||
- 🔒️(backend) add a daily cap on room creation
|
||||
|
||||
+25
-76
@@ -16,54 +16,43 @@ the following command inside your docker container:
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Marketing / Brevo integration now uses `django-lasuite`
|
||||
### Purging inactive rooms
|
||||
|
||||
The in-house marketing service (`core.services.marketing`) has been removed and
|
||||
replaced by the shared implementation from `django-lasuite`
|
||||
(`lasuite.marketing`). This fixes a bug where updating a user's contact on
|
||||
Brevo overwrote their list memberships, removing lists set by other
|
||||
La Suite products. Existing lists are now preserved and merged.
|
||||
Rooms now keep track of the last time they were started (`last_started_at`), fed by LiveKit's `room_started` webhook. A new `purge_inactive_rooms` management command permanently deletes the rooms that have not been started for `ROOM_INACTIVITY_DELETION_DAYS` days. See [the room purge documentation](docs/features/room-purge.md).
|
||||
|
||||
**Celery worker required.** Newsletter signup on login
|
||||
(`SIGNUP_NEW_USER_TO_MARKETING_EMAIL=True`) is now dispatched as an
|
||||
asynchronous Celery task (`lasuite.marketing.tasks.create_or_update_contact`)
|
||||
instead of a synchronous call with a 1s timeout. Make sure a Celery worker is
|
||||
running alongside the backend, otherwise contacts will never be pushed to Brevo.
|
||||
- The feature is **disabled by default**: nothing is deleted unless you set `ROOM_INACTIVITY_DELETION_DAYS`.
|
||||
- The migration marks every existing room as started at the time of the upgrade, so no existing room can be purged before a full inactivity period has elapsed after upgrading.
|
||||
- Rooms holding a saved recording their users may still access are kept: any saved recording, or, when `RECORDING_EXPIRATION_DAYS` is set, a saved recording created within that window.
|
||||
- Inactivity is measured from LiveKit's `room_started` webhook: if it is not delivered to your backend, rooms in daily use look inactive and get purged.
|
||||
- When a room is purged, all it's configuration and access rights are also deleted. Its slug becomes available again and can be reused when a meeting is created from that same URL.
|
||||
|
||||
**Configuration changes.** The following environment variables / settings are
|
||||
**removed** and no longer read:
|
||||
* With `ALLOW_UNREGISTERED_ROOMS=false`, only an authenticated user can navigate to a previously existing link after the room has been purged. Doing so recreates the room in the database with a fresh configuration, with that user associated with it and granted admin rights.
|
||||
* With `ALLOW_UNREGISTERED_ROOMS=true`, any user can reopen the purged room by navigating to the same URL. In that case, the room is created dynamically and no corresponding room entry is persisted in the database.
|
||||
|
||||
- `MARKETING_SERVICE_CLASS`
|
||||
- `BREVO_API_KEY`
|
||||
- `BREVO_API_CONTACT_LIST_IDS`
|
||||
- `BREVO_API_CONTACT_ATTRIBUTES` (previous default: `{"VISIO_USER": True}`)
|
||||
- `BREVO_API_TIMEOUT`
|
||||
### Local development: MinIO replaced by Garage
|
||||
|
||||
They are replaced by a single `LASUITE_MARKETING` setting, configured through:
|
||||
The development stacks now use [Garage](https://garagehq.deuxfleurs.fr/) instead of MinIO as S3 storage. Garage keeps its own format in `data/media/meta` and `data/media/data` and cannot read what MinIO left there, so local recordings and files will be lost.
|
||||
|
||||
| Variable | Default | Description |
|
||||
| ------------------------------ | ------------------------------------------------ | -------------------------------------------- |
|
||||
| `LASUITE_MARKETING_BACKEND` | `lasuite.marketing.backends.dummy.DummyBackend` | Backend class path |
|
||||
| `LASUITE_MARKETING_PARAMETERS` | `{}` | Keyword arguments passed to the backend |
|
||||
To migrate a local environment:
|
||||
|
||||
⚠️ The default backend is now a **dummy** (no-op). If you previously used
|
||||
Brevo, you must explicitly configure it, otherwise signups are silently dropped:
|
||||
1. Stop the stack and remove its containers, including the former `minio` one: `docker compose down --remove-orphans`
|
||||
2. Optionally reclaim the space used by MinIO: `rm -rf data/media && make data/media`
|
||||
3. In your `env.d/development/*` files, replace `minio:9000` by `garage:9000`, the `meet` / `password` credentials by `meet-access-key` / `meet-secret-access-key`, and add `AWS_S3_REGION_NAME=local` (or delete these files and run `make create-env-files`)
|
||||
4. Run `make create-env-files` to generate `env.d/development/garage`, which holds a random RPC secret for Garage.
|
||||
5. Rebuild the images, since the summary and agent images now install boto3 instead of minio
|
||||
|
||||
LASUITE_MARKETING_BACKEND=lasuite.marketing.backends.brevo.BrevoBackend
|
||||
LASUITE_MARKETING_PARAMETERS={"api_key": "<your-brevo-api-key>", "api_contact_list_ids": [1, 2], "api_contact_attributes": {"VISIO_USER": True}}
|
||||
### Summary service and metadata collector: boto3 replaces the minio client
|
||||
|
||||
Migration mapping:
|
||||
The summary service and the metadata collector agent now talk to S3 through boto3 instead of the minio client, with the same settings.
|
||||
Requests are now signed for `AWS_S3_REGION_NAME` as-is. When it is not set, the region is no longer looked up from the bucket: boto3 falls back to `AWS_DEFAULT_REGION`, then to `us-east-1`. If you left `AWS_S3_REGION_NAME` unset, set it to your provider's region before upgrading, or providers that check the signing region will reject the transcripts, summaries and meeting metadata uploads, as well as their signed URLs.
|
||||
|
||||
- `BREVO_API_KEY` → `api_key`
|
||||
- `BREVO_API_CONTACT_LIST_IDS` → `api_contact_list_ids`
|
||||
- `BREVO_API_CONTACT_ATTRIBUTES` → `api_contact_attributes` (re-add
|
||||
`{"VISIO_USER": True}` if you relied on the old default)
|
||||
- `BREVO_API_TIMEOUT` → no equivalent (the request runs in a background task)
|
||||
Also:
|
||||
- Signed URLs to transcripts and summaries are now always path-style (`<endpoint>/<bucket>/<key>`), whereas the minio client used virtual-hosted-style URLs
|
||||
- The metadata collector now accepts `AWS_S3_ENDPOINT_URL` with or without a scheme, like the summary service: the scheme always follows `AWS_S3_SECURE_ACCESS`.
|
||||
|
||||
Note: `BREVO_API_KEY` used to support being read from a secret file; the API key
|
||||
now lives inside `LASUITE_MARKETING_PARAMETERS`, so adapt how you inject that
|
||||
secret (e.g. build the whole variable from your secret store).
|
||||
### Helm chart: media services default to Garage
|
||||
|
||||
The `meet` chart now defaults `serviceMedia.host` and `serviceMediaFiles.host` to `garage.meet.svc.cluster.local`, and the `upstream-vhost` annotation of `ingressMedia` and `ingressMediaFiles` to `garage.meet.svc.cluster.local:9000`. If you relied on the former `minio.meet.svc.cluster.local` defaults, set these values explicitly to your S3 service before upgrading, or recordings and files stop being served under `/media`.
|
||||
### Recording encoding settings replaced by a resolution/profile model
|
||||
|
||||
The `RECORDING_ENCODING_*` settings introduced in v1.16.0 exposed raw encoder
|
||||
@@ -186,46 +175,6 @@ Before enabling it:
|
||||
See [docs/features/recording.md](docs/features/recording.md#tuning-recording-encoding)
|
||||
for the full setting reference, the shipped profile table and the tuning caveats.
|
||||
|
||||
## v1.33.0
|
||||
|
||||
### Purging inactive rooms
|
||||
|
||||
Rooms now keep track of the last time they were started (`last_started_at`), fed by LiveKit's `room_started` webhook. A new `purge_inactive_rooms` management command permanently deletes the rooms that have not been started for `ROOM_INACTIVITY_DELETION_DAYS` days. See [the room purge documentation](docs/features/room-purge.md).
|
||||
|
||||
- The feature is **disabled by default**: nothing is deleted unless you set `ROOM_INACTIVITY_DELETION_DAYS`.
|
||||
- The migration marks every existing room as started at the time of the upgrade, so no existing room can be purged before a full inactivity period has elapsed after upgrading.
|
||||
- Rooms holding a saved recording their users may still access are kept: any saved recording, or, when `RECORDING_EXPIRATION_DAYS` is set, a saved recording created within that window.
|
||||
- Inactivity is measured from LiveKit's `room_started` webhook: if it is not delivered to your backend, rooms in daily use look inactive and get purged.
|
||||
- When a room is purged, all it's configuration and access rights are also deleted. Its slug becomes available again and can be reused when a meeting is created from that same URL.
|
||||
|
||||
* With `ALLOW_UNREGISTERED_ROOMS=false`, only an authenticated user can navigate to a previously existing link after the room has been purged. Doing so recreates the room in the database with a fresh configuration, with that user associated with it and granted admin rights.
|
||||
* With `ALLOW_UNREGISTERED_ROOMS=true`, any user can reopen the purged room by navigating to the same URL. In that case, the room is created dynamically and no corresponding room entry is persisted in the database.
|
||||
|
||||
### Local development: MinIO replaced by Garage
|
||||
|
||||
The development stacks now use [Garage](https://garagehq.deuxfleurs.fr/) instead of MinIO as S3 storage. Garage keeps its own format in `data/media/meta` and `data/media/data` and cannot read what MinIO left there, so local recordings and files will be lost.
|
||||
|
||||
To migrate a local environment:
|
||||
|
||||
1. Stop the stack and remove its containers, including the former `minio` one: `docker compose down --remove-orphans`
|
||||
2. Optionally reclaim the space used by MinIO: `rm -rf data/media && make data/media`
|
||||
3. In your `env.d/development/*` files, replace `minio:9000` by `garage:9000`, the `meet` / `password` credentials by `meet-access-key` / `meet-secret-access-key`, and add `AWS_S3_REGION_NAME=local` (or delete these files and run `make create-env-files`)
|
||||
4. Run `make create-env-files` to generate `env.d/development/garage`, which holds a random RPC secret for Garage.
|
||||
5. Rebuild the images, since the summary and agent images now install boto3 instead of minio
|
||||
|
||||
### Summary service and metadata collector: boto3 replaces the minio client
|
||||
|
||||
The summary service and the metadata collector agent now talk to S3 through boto3 instead of the minio client, with the same settings.
|
||||
Requests are now signed for `AWS_S3_REGION_NAME` as-is. When it is not set, the region is no longer looked up from the bucket: boto3 falls back to `AWS_DEFAULT_REGION`. If you left `AWS_S3_REGION_NAME` unset, set it to your provider's region before upgrading, or providers that check the signing region will reject the transcripts, summaries and meeting metadata uploads, as well as their signed URLs.
|
||||
|
||||
Also:
|
||||
- Signed URLs to transcripts and summaries are now always path-style (`<endpoint>/<bucket>/<key>`), whereas the minio client used virtual-hosted-style URLs
|
||||
- The metadata collector now accepts `AWS_S3_ENDPOINT_URL` with or without a scheme, like the summary service: the scheme always follows `AWS_S3_SECURE_ACCESS`.
|
||||
|
||||
### Helm chart: media services default to Garage
|
||||
|
||||
The `meet` chart now defaults `serviceMedia.host` and `serviceMediaFiles.host` to `garage.meet.svc.cluster.local`, and the `upstream-vhost` annotation of `ingressMedia` and `ingressMediaFiles` to `garage.meet.svc.cluster.local:9000`. If you relied on the former `minio.meet.svc.cluster.local` defaults, set these values explicitly to your S3 service before upgrading, or recordings and files stop being served under `/media`.
|
||||
|
||||
## v1.30.0
|
||||
|
||||
### Removing S3 storage-event webhooks for recordings
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
|
||||
[project]
|
||||
name = "agents"
|
||||
version = "1.34.0"
|
||||
version = "1.33.0"
|
||||
requires-python = ">=3.12"
|
||||
dependencies = [
|
||||
"livekit-agents==1.7.0",
|
||||
|
||||
Generated
+1
-1
@@ -9,7 +9,7 @@ resolution-markers = [
|
||||
|
||||
[[package]]
|
||||
name = "agents"
|
||||
version = "1.34.0"
|
||||
version = "1.33.0"
|
||||
source = { virtual = "." }
|
||||
dependencies = [
|
||||
{ name = "boto3" },
|
||||
|
||||
@@ -483,11 +483,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):
|
||||
|
||||
@@ -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" # noqa: S105
|
||||
CLIENT_SECRET_HASH_VERSION = "v0" # noqa: S105
|
||||
CLIENT_SECRET_HASH_PREFIX = (
|
||||
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", "0024_room_last_started_at"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="application",
|
||||
name="client_secret_sha256",
|
||||
field=models.CharField(
|
||||
max_length=255, null=True, blank=True
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -1,23 +0,0 @@
|
||||
# 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', '0025_application_client_secret_sha256'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
AddIndexConcurrently(
|
||||
model_name="user",
|
||||
index=models.Index(
|
||||
Upper("email"),
|
||||
name="user_email_upper_idx",
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -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,7 @@ 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 .recording.enums import FileExtension
|
||||
from .validators import sub_validator
|
||||
|
||||
@@ -246,9 +245,6 @@ class User(AbstractBaseUser, BaseModel, auth_models.PermissionsMixin):
|
||||
name="unique_email_when_sub_is_null",
|
||||
)
|
||||
]
|
||||
indexes = [
|
||||
models.Index(models.functions.Upper("email"), name="user_email_upper_idx"),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return self.email or self.admin_email or str(self.id)
|
||||
@@ -828,9 +824,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,
|
||||
@@ -846,63 +839,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."""
|
||||
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -1070,7 +1070,7 @@ class Base(Configuration):
|
||||
environ_prefix=None,
|
||||
)
|
||||
APPLICATION_CLIENT_SECRET_LENGTH = values.PositiveIntegerValue(
|
||||
50,
|
||||
128,
|
||||
environ_name="APPLICATION_CLIENT_SECRET_LENGTH",
|
||||
environ_prefix=None,
|
||||
)
|
||||
@@ -1432,20 +1432,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(
|
||||
@@ -1531,7 +1517,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.34.0"
|
||||
version = "1.33.0"
|
||||
authors = [{ "name" = "DINUM", "email" = "dev@mail.numerique.gouv.fr" }]
|
||||
classifiers = [
|
||||
"Development Status :: 5 - Production/Stable",
|
||||
|
||||
Generated
+1
-1
@@ -1297,7 +1297,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "meet"
|
||||
version = "1.34.0"
|
||||
version = "1.33.0"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "aiohttp" },
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "meet",
|
||||
"version": "1.34.0",
|
||||
"version": "1.33.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "meet",
|
||||
"version": "1.34.0",
|
||||
"version": "1.33.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.34.0",
|
||||
"version": "1.33.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "panda codegen && vite",
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "mail_mjml",
|
||||
"version": "1.34.0",
|
||||
"version": "1.33.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "mail_mjml",
|
||||
"version": "1.34.0",
|
||||
"version": "1.33.0",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@html-to/text-cli": "0.6.1",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "mail_mjml",
|
||||
"version": "1.34.0",
|
||||
"version": "1.33.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.34.0",
|
||||
"version": "1.33.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "sdk",
|
||||
"version": "1.34.0",
|
||||
"version": "1.33.0",
|
||||
"license": "ISC",
|
||||
"workspaces": [
|
||||
"./library",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "sdk",
|
||||
"version": "1.34.0",
|
||||
"version": "1.33.0",
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"description": "",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
|
||||
[project]
|
||||
name = "summary"
|
||||
version = "1.34.0"
|
||||
version = "1.33.0"
|
||||
requires-python = ">=3.13"
|
||||
dependencies = [
|
||||
"fastapi[standard]>=0.105.0",
|
||||
|
||||
Generated
+1
-1
@@ -1484,7 +1484,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "summary"
|
||||
version = "1.34.0"
|
||||
version = "1.33.0"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "boto3" },
|
||||
|
||||
Reference in New Issue
Block a user