feat: add cmshopee authorization shadow routes
This commit is contained in:
@@ -5,6 +5,10 @@ from .views import (
|
||||
AnalyzeImagesView,
|
||||
BalanceView,
|
||||
ClientLatestReleaseView,
|
||||
CmshopeeAnalyzeImagesView,
|
||||
CmshopeeGenerateImageTaskDetailView,
|
||||
CmshopeeGenerateImageTaskSubmitView,
|
||||
CmshopeeGenerateTitleView,
|
||||
DeviceHeartbeatView,
|
||||
DeviceRegistrationView,
|
||||
GenerateImageTaskDetailView,
|
||||
@@ -48,18 +52,38 @@ urlpatterns = [
|
||||
name="api-client-release-latest",
|
||||
),
|
||||
path("v1/generate/title", GenerateTitleView.as_view(), name="api-generate-title"),
|
||||
path(
|
||||
"v1/cmshopee/generate/title",
|
||||
CmshopeeGenerateTitleView.as_view(),
|
||||
name="api-cmshopee-generate-title",
|
||||
),
|
||||
path("v1/analyze/images", AnalyzeImagesView.as_view(), name="api-analyze-images"),
|
||||
path(
|
||||
"v1/cmshopee/analyze/images",
|
||||
CmshopeeAnalyzeImagesView.as_view(),
|
||||
name="api-cmshopee-analyze-images",
|
||||
),
|
||||
path("v1/generate/image", GenerateImageView.as_view(), name="api-generate-image"),
|
||||
path(
|
||||
"v1/generate/image/tasks",
|
||||
GenerateImageTaskSubmitView.as_view(),
|
||||
name="api-generate-image-task-submit",
|
||||
),
|
||||
path(
|
||||
"v1/cmshopee/generate/image/tasks",
|
||||
CmshopeeGenerateImageTaskSubmitView.as_view(),
|
||||
name="api-cmshopee-generate-image-task-submit",
|
||||
),
|
||||
path(
|
||||
"v1/generate/image/tasks/<uuid:task_id>",
|
||||
GenerateImageTaskDetailView.as_view(),
|
||||
name="api-generate-image-task-detail",
|
||||
),
|
||||
path(
|
||||
"v1/cmshopee/generate/image/tasks/<uuid:task_id>",
|
||||
CmshopeeGenerateImageTaskDetailView.as_view(),
|
||||
name="api-cmshopee-generate-image-task-detail",
|
||||
),
|
||||
path("v1/recharge/create", RechargeCreateView.as_view(), name="api-recharge-create"),
|
||||
path("v1/recharge/status", RechargeStatusView.as_view(), name="api-recharge-status"),
|
||||
path(
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import logging
|
||||
|
||||
from django.http import HttpResponse
|
||||
from django.conf import settings
|
||||
from django.utils import timezone
|
||||
from django.utils.decorators import method_decorator
|
||||
from django.views.decorators.csrf import csrf_exempt
|
||||
@@ -70,6 +71,7 @@ from apps.licensing.services import (
|
||||
DeviceRegistrationError,
|
||||
DeviceSessionValidationError,
|
||||
create_migration_request,
|
||||
evaluate_device_authorization,
|
||||
record_device_heartbeat,
|
||||
register_device,
|
||||
resolve_optional_device_session,
|
||||
@@ -77,6 +79,7 @@ from apps.licensing.services import (
|
||||
from apps.licensing.models import MigrationRequest
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
authorization_logger = logging.getLogger("cmhub.licensing.authorization")
|
||||
|
||||
|
||||
class ExternalApiView(APIView):
|
||||
@@ -379,6 +382,56 @@ class GenerateImageTaskDetailView(ExternalApiView):
|
||||
return Response(task_detail_response(task), status=status.HTTP_200_OK)
|
||||
|
||||
|
||||
class CmshopeeShadowAuthorizationMixin:
|
||||
product_code = "cmshopee"
|
||||
|
||||
def optional_client_device(self, request):
|
||||
device = super().optional_client_device(request)
|
||||
decision = evaluate_device_authorization(
|
||||
user=request.user,
|
||||
product_code=self.product_code,
|
||||
device=device,
|
||||
raw_credential_token=request.headers.get("X-Device-Credential", ""),
|
||||
)
|
||||
event = {
|
||||
"event": "cmshopee_authorization_shadow",
|
||||
"product_code": self.product_code,
|
||||
"user_id": request.user.id,
|
||||
"client_device_id": getattr(device, "id", None),
|
||||
"credential_id": decision.credential_id,
|
||||
"would_reject": decision.would_reject,
|
||||
"would_reject_code": decision.code,
|
||||
"shadow_mode": settings.CMSHOPEE_AUTHORIZATION_SHADOW_MODE,
|
||||
}
|
||||
authorization_logger.info(
|
||||
"%s %s",
|
||||
event["event"],
|
||||
event,
|
||||
extra={"cmshopee_authorization": event},
|
||||
)
|
||||
request.cmshopee_authorization = decision
|
||||
return device
|
||||
|
||||
|
||||
class CmshopeeGenerateTitleView(CmshopeeShadowAuthorizationMixin, GenerateTitleView):
|
||||
pass
|
||||
|
||||
|
||||
class CmshopeeAnalyzeImagesView(CmshopeeShadowAuthorizationMixin, AnalyzeImagesView):
|
||||
pass
|
||||
|
||||
|
||||
class CmshopeeGenerateImageTaskSubmitView(
|
||||
CmshopeeShadowAuthorizationMixin,
|
||||
GenerateImageTaskSubmitView,
|
||||
):
|
||||
pass
|
||||
|
||||
|
||||
class CmshopeeGenerateImageTaskDetailView(GenerateImageTaskDetailView):
|
||||
pass
|
||||
|
||||
|
||||
class BalanceView(ExternalApiView):
|
||||
def get(self, request):
|
||||
balance = get_balance_snapshot(request.user)
|
||||
|
||||
@@ -39,6 +39,18 @@ class LicensingError(Exception):
|
||||
super().__init__(message)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AuthorizationDecision:
|
||||
product_code: str
|
||||
allowed: bool
|
||||
code: str
|
||||
credential_id: int | None = None
|
||||
|
||||
@property
|
||||
def would_reject(self) -> bool:
|
||||
return not self.allowed
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DeviceRegistrationResult:
|
||||
device: ClientDevice
|
||||
@@ -579,3 +591,32 @@ def revoke_device_credential(*, credential: DeviceCredential, reason: str, actor
|
||||
now=now,
|
||||
)
|
||||
return locked_credential
|
||||
|
||||
|
||||
def evaluate_device_authorization(*, user, product_code: str, device=None, raw_credential_token: str = "", now=None):
|
||||
now = now or timezone.now()
|
||||
if device is None:
|
||||
return AuthorizationDecision(product_code, False, "device_not_bound")
|
||||
if device.user_id != user.id or device.product_code != product_code:
|
||||
return AuthorizationDecision(product_code, False, "device_mismatch")
|
||||
|
||||
raw_credential_token = str(raw_credential_token or "").strip()
|
||||
if not raw_credential_token:
|
||||
return AuthorizationDecision(product_code, False, "license_required")
|
||||
credential = (
|
||||
DeviceCredential.objects.select_related("device", "entitlement", "seat")
|
||||
.filter(token_hash=MigrationRequest.hash_credential_token(raw_credential_token))
|
||||
.first()
|
||||
)
|
||||
if credential is None:
|
||||
return AuthorizationDecision(product_code, False, "license_required")
|
||||
if (
|
||||
credential.user_id != user.id
|
||||
or credential.product_code != product_code
|
||||
or credential.device_id != device.id
|
||||
or credential.seat.device_id != device.id
|
||||
):
|
||||
return AuthorizationDecision(product_code, False, "device_mismatch", credential.id)
|
||||
if not credential.is_active_at(now) or not credential.entitlement.is_usable_at(now):
|
||||
return AuthorizationDecision(product_code, False, "license_expired", credential.id)
|
||||
return AuthorizationDecision(product_code, True, "", credential.id)
|
||||
|
||||
@@ -26,6 +26,7 @@ from apps.licensing.services import (
|
||||
confirm_migration_request,
|
||||
create_legacy_migration_grant,
|
||||
create_migration_request,
|
||||
evaluate_device_authorization,
|
||||
grant_software_entitlement,
|
||||
record_device_heartbeat,
|
||||
release_license_seat,
|
||||
@@ -625,3 +626,50 @@ class LegacyMigrationFlowTests(TestCase):
|
||||
now=timezone.now(),
|
||||
)
|
||||
self.assertFalse(DeviceCredential.objects.exists())
|
||||
|
||||
def test_authorization_decision_handles_missing_mismatched_and_expired_credentials(self):
|
||||
self.grant_migration()
|
||||
migration_request, raw_token = create_migration_request(
|
||||
user=self.user,
|
||||
device=self.device,
|
||||
)
|
||||
_request, credential, created = confirm_migration_request(
|
||||
request_id=migration_request.request_id,
|
||||
user=self.user,
|
||||
)
|
||||
self.assertTrue(created)
|
||||
self.assertEqual(
|
||||
evaluate_device_authorization(
|
||||
user=self.user,
|
||||
product_code="cmshopee",
|
||||
).code,
|
||||
"device_not_bound",
|
||||
)
|
||||
self.assertEqual(
|
||||
evaluate_device_authorization(
|
||||
user=self.user,
|
||||
product_code="cmshopee",
|
||||
device=self.device,
|
||||
).code,
|
||||
"license_required",
|
||||
)
|
||||
allowed = evaluate_device_authorization(
|
||||
user=self.user,
|
||||
product_code="cmshopee",
|
||||
device=self.device,
|
||||
raw_credential_token=raw_token,
|
||||
)
|
||||
self.assertTrue(allowed.allowed)
|
||||
self.assertEqual(allowed.credential_id, credential.id)
|
||||
|
||||
DeviceCredential.objects.filter(pk=credential.pk).update(
|
||||
expires_at=timezone.now() - timedelta(seconds=1)
|
||||
)
|
||||
expired = evaluate_device_authorization(
|
||||
user=self.user,
|
||||
product_code="cmshopee",
|
||||
device=self.device,
|
||||
raw_credential_token=raw_token,
|
||||
)
|
||||
self.assertTrue(expired.would_reject)
|
||||
self.assertEqual(expired.code, "license_expired")
|
||||
|
||||
Reference in New Issue
Block a user