feat: add device registration observation

This commit is contained in:
QiuSW
2026-07-21 09:27:05 +08:00
parent 20bdf4a8ce
commit 69f4517958
21 changed files with 958 additions and 5 deletions
+26
View File
@@ -4,6 +4,7 @@ from django.conf import settings
from rest_framework import serializers
from apps.billing.models import RechargeOrder
from apps.licensing.models import ClientDevice
class GenerateTitleRequestSerializer(serializers.Serializer):
@@ -129,3 +130,28 @@ class RechargeStatusRequestSerializer(serializers.Serializer):
allow_blank=False,
max_length=64,
)
class DeviceRegistrationRequestSerializer(serializers.Serializer):
product_code = serializers.ChoiceField(choices=ClientDevice.ProductCode.values)
device_id = serializers.CharField(
trim_whitespace=True,
allow_blank=False,
max_length=256,
)
device_id_version = serializers.CharField(
trim_whitespace=True,
allow_blank=False,
max_length=32,
)
installation_public_key = serializers.CharField(
trim_whitespace=True,
allow_blank=False,
max_length=4096,
)
platform = serializers.ChoiceField(choices=ClientDevice.Platform.values)
client_version = serializers.CharField(
trim_whitespace=True,
allow_blank=False,
max_length=64,
)
+12
View File
@@ -5,6 +5,8 @@ from .views import (
AnalyzeImagesView,
BalanceView,
ClientLatestReleaseView,
DeviceHeartbeatView,
DeviceRegistrationView,
GenerateImageTaskDetailView,
GenerateImageTaskSubmitView,
GenerateImageView,
@@ -18,6 +20,16 @@ from .views import (
urlpatterns = [
path("v1/balance", BalanceView.as_view(), name="api-balance"),
path("v1/models", ModelsView.as_view(), name="api-models"),
path(
"v1/client/devices/register",
DeviceRegistrationView.as_view(),
name="api-client-device-register",
),
path(
"v1/client/devices/heartbeat",
DeviceHeartbeatView.as_view(),
name="api-client-device-heartbeat",
),
path(
"v1/client/releases/latest",
ClientLatestReleaseView.as_view(),
+83
View File
@@ -27,6 +27,7 @@ from apps.api.image_tasks import (
from apps.api.models import ImageGenerationTask
from apps.api.serializers import (
AnalyzeImagesRequestSerializer,
DeviceRegistrationRequestSerializer,
GenerateImageRequestSerializer,
GenerateTitleRequestSerializer,
RechargeCreateRequestSerializer,
@@ -63,6 +64,12 @@ from apps.billing.services import (
query_and_apply_recharge_payment,
)
from apps.portal.models import DownloadRelease
from apps.licensing.authentication import DeviceSessionAuthentication
from apps.licensing.services import (
DeviceRegistrationError,
record_device_heartbeat,
register_device,
)
logger = logging.getLogger(__name__)
@@ -263,6 +270,82 @@ class ModelsView(ExternalApiView):
)
def _device_response(device) -> dict:
return {
"product_code": device.product_code,
"platform": device.platform,
"client_version": device.client_version,
"status": device.status,
"first_seen_at": device.first_seen_at.isoformat(),
"last_seen_at": device.last_seen_at.isoformat(),
}
class DeviceRegistrationView(ExternalApiView):
def post(self, request):
serializer = DeviceRegistrationRequestSerializer(data=request.data)
if not serializer.is_valid():
return Response(
api_error("bad_request", "参数错误"),
status=status.HTTP_400_BAD_REQUEST,
)
data = serializer.validated_data
try:
result = register_device(
user=request.user,
api_key=request.auth,
product_code=data["product_code"],
device_id_version=data["device_id_version"],
device_id=data["device_id"],
public_key=data["installation_public_key"],
platform=data["platform"],
client_version=data["client_version"],
)
except DeviceRegistrationError as exc:
return Response(
api_error(exc.code, exc.message),
status=status.HTTP_403_FORBIDDEN,
)
return Response(
{
"device": _device_response(result.device),
"device_session_token": result.session_token,
"expires_at": result.session.expires_at.isoformat(),
},
status=status.HTTP_201_CREATED if result.created else status.HTTP_200_OK,
)
class DeviceSessionApiView(APIView):
authentication_classes = (DeviceSessionAuthentication,)
permission_classes = (IsAuthenticated,)
def permission_denied(self, request, message=None, code=None):
if request.authenticators and not request.successful_authenticator:
raise DeviceSessionAuthentication.authentication_failed(request)
super().permission_denied(request, message=message, code=code)
class DeviceHeartbeatView(DeviceSessionApiView):
def post(self, request):
try:
updated = record_device_heartbeat(request.auth)
except DeviceRegistrationError as exc:
return Response(
api_error(exc.code, exc.message),
status=status.HTTP_403_FORBIDDEN,
)
request.auth.device.refresh_from_db()
return Response(
{
"device": _device_response(request.auth.device),
"activity_updated": updated,
"expires_at": request.auth.expires_at.isoformat(),
},
status=status.HTTP_200_OK,
)
def _release_unpublished_response(platform: str) -> dict:
return {
"platform": platform,
+1
View File
@@ -0,0 +1 @@
+107
View File
@@ -0,0 +1,107 @@
from django.contrib import admin
from .models import ClientDevice, DeviceBindingAudit, DeviceSession
def _masked_fingerprint(value: str) -> str:
if not value:
return ""
return f"{value[:8]}...{value[-6:]}"
@admin.register(ClientDevice)
class ClientDeviceAdmin(admin.ModelAdmin):
list_display = (
"user",
"product_code",
"platform",
"client_version",
"status",
"last_seen_at",
"first_seen_at",
)
list_filter = ("product_code", "platform", "status", "last_seen_at")
search_fields = ("user__username", "user__email")
list_select_related = ("user",)
ordering = ("-last_seen_at", "-id")
readonly_fields = (
"user",
"product_code",
"device_id_version",
"device_fingerprint_masked",
"public_key_fingerprint_masked",
"platform",
"client_version",
"status",
"first_seen_at",
"last_seen_at",
"created_at",
"updated_at",
)
fields = readonly_fields
def has_add_permission(self, request):
return False
def has_delete_permission(self, request, obj=None):
return False
@admin.display(description="设备摘要")
def device_fingerprint_masked(self, obj):
return _masked_fingerprint(obj.device_fingerprint)
@admin.display(description="安装公钥摘要")
def public_key_fingerprint_masked(self, obj):
return _masked_fingerprint(obj.public_key_fingerprint)
@admin.register(DeviceSession)
class DeviceSessionAdmin(admin.ModelAdmin):
list_display = ("device", "expires_at", "last_used_at", "revoked_at", "created_at")
list_filter = ("revoked_at", "expires_at")
search_fields = ("device__user__username", "device__user__email")
list_select_related = ("device", "device__user")
readonly_fields = (
"device",
"expires_at",
"last_used_at",
"revoked_at",
"created_at",
)
fields = readonly_fields
def has_add_permission(self, request):
return False
def has_change_permission(self, request, obj=None):
return False
def has_delete_permission(self, request, obj=None):
return False
@admin.register(DeviceBindingAudit)
class DeviceBindingAuditAdmin(admin.ModelAdmin):
list_display = ("user", "device", "api_key", "action", "reason", "created_at")
list_filter = ("action", "created_at")
search_fields = ("user__username", "user__email", "api_key__key_prefix")
list_select_related = ("user", "device", "api_key")
readonly_fields = (
"user",
"device",
"api_key",
"action",
"reason",
"client_version",
"created_at",
)
fields = readonly_fields
def has_add_permission(self, request):
return False
def has_change_permission(self, request, obj=None):
return False
def has_delete_permission(self, request, obj=None):
return False
+7
View File
@@ -0,0 +1,7 @@
from django.apps import AppConfig
class LicensingConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "apps.licensing"
verbose_name = "软件授权"
+41
View File
@@ -0,0 +1,41 @@
from __future__ import annotations
from django.utils import timezone
from rest_framework.authentication import BaseAuthentication
from rest_framework.exceptions import AuthenticationFailed, PermissionDenied
from apps.api.errors import api_error
from apps.api.throttles import throttle_api_auth_failure
from apps.licensing.models import ClientDevice, DeviceSession
class DeviceSessionAuthentication(BaseAuthentication):
header_name = "HTTP_X_DEVICE_SESSION"
def authenticate(self, request):
raw_token = request.META.get(self.header_name, "").strip()
if not raw_token:
return None
try:
session = DeviceSession.objects.select_related("device", "device__user").get(
token_hash=DeviceSession.hash_token(raw_token)
)
except DeviceSession.DoesNotExist as exc:
raise self.authentication_failed(request) from exc
if not session.matches_token(raw_token) or not session.is_active_at(timezone.now()):
raise self.authentication_failed(request)
if session.device.status != ClientDevice.Status.ACTIVE:
raise PermissionDenied(api_error("device_revoked", "设备已被吊销"))
if not session.device.user.is_business_active:
raise PermissionDenied(api_error("account_disabled", "账号或设备已禁用"))
return session.device.user, session
@staticmethod
def authentication_failed(request):
throttle_api_auth_failure(request)
return AuthenticationFailed(api_error("device_session_invalid", "缺失或无效设备会话"))
def authenticate_header(self, request) -> str:
return "DeviceSession"
+107
View File
@@ -0,0 +1,107 @@
# Generated by Django 5.2.15 on 2026-07-21 01:16
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
('users', '0005_alter_apikey_created_at_alter_apikey_key_hash_and_more'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='ClientDevice',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('product_code', models.CharField(choices=[('cmshopee', '虾皮圈优化助手')], max_length=32, verbose_name='产品代码')),
('device_id_version', models.CharField(max_length=32, verbose_name='设备标识版本')),
('device_fingerprint', models.CharField(editable=False, max_length=64, verbose_name='设备摘要')),
('public_key_fingerprint', models.CharField(editable=False, max_length=64, verbose_name='安装公钥摘要')),
('platform', models.CharField(choices=[('windows', 'Windows'), ('macos', 'macOS'), ('linux', 'Linux')], max_length=16, verbose_name='平台')),
('client_version', models.CharField(max_length=64, verbose_name='客户端版本')),
('status', models.CharField(choices=[('active', '有效'), ('revoked', '已吊销')], default='active', max_length=20, verbose_name='状态')),
('first_seen_at', models.DateTimeField(auto_now_add=True, verbose_name='首次登记时间')),
('last_seen_at', models.DateTimeField(auto_now_add=True, verbose_name='最后活跃时间')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='更新时间')),
('user', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='client_devices', to=settings.AUTH_USER_MODEL, verbose_name='用户')),
],
options={
'verbose_name': '客户端设备',
'verbose_name_plural': '客户端设备',
'db_table': 'client_device',
'ordering': ('-last_seen_at', '-id'),
},
),
migrations.CreateModel(
name='DeviceBindingAudit',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('action', models.CharField(choices=[('registered', '已登记'), ('session_issued', '会话已签发'), ('heartbeat', '心跳'), ('rejected', '请求被拒绝'), ('revoked', '设备已吊销')], max_length=32, verbose_name='动作')),
('reason', models.CharField(blank=True, max_length=120, verbose_name='原因')),
('client_version', models.CharField(blank=True, max_length=64, verbose_name='客户端版本')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')),
('api_key', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='device_binding_audits', to='users.apikey', verbose_name='来源 API 密钥')),
('device', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='audits', to='licensing.clientdevice', verbose_name='客户端设备')),
('user', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='device_binding_audits', to=settings.AUTH_USER_MODEL, verbose_name='用户')),
],
options={
'verbose_name': '设备绑定审计',
'verbose_name_plural': '设备绑定审计',
'db_table': 'device_binding_audit',
'ordering': ('-created_at', '-id'),
},
),
migrations.CreateModel(
name='DeviceSession',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('token_hash', models.CharField(editable=False, max_length=64, unique=True, verbose_name='会话令牌哈希')),
('expires_at', models.DateTimeField(verbose_name='过期时间')),
('last_used_at', models.DateTimeField(blank=True, null=True, verbose_name='最后使用时间')),
('revoked_at', models.DateTimeField(blank=True, null=True, verbose_name='吊销时间')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')),
('device', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='sessions', to='licensing.clientdevice', verbose_name='客户端设备')),
],
options={
'verbose_name': '设备会话',
'verbose_name_plural': '设备会话',
'db_table': 'device_session',
'ordering': ('-created_at', '-id'),
},
),
migrations.AddIndex(
model_name='clientdevice',
index=models.Index(fields=['user', 'product_code', 'status'], name='client_devi_user_id_bdec20_idx'),
),
migrations.AddIndex(
model_name='clientdevice',
index=models.Index(fields=['product_code', 'last_seen_at'], name='client_devi_product_a3ba66_idx'),
),
migrations.AddConstraint(
model_name='clientdevice',
constraint=models.UniqueConstraint(fields=('user', 'product_code', 'device_fingerprint'), name='client_device_user_product_fingerprint_unique'),
),
migrations.AddIndex(
model_name='devicebindingaudit',
index=models.Index(fields=['user', 'created_at'], name='device_bind_user_id_d2befc_idx'),
),
migrations.AddIndex(
model_name='devicebindingaudit',
index=models.Index(fields=['device', 'created_at'], name='device_bind_device__f566a5_idx'),
),
migrations.AddIndex(
model_name='devicesession',
index=models.Index(fields=['device', 'expires_at'], name='device_sess_device__ab6307_idx'),
),
migrations.AddIndex(
model_name='devicesession',
index=models.Index(fields=['expires_at', 'revoked_at'], name='device_sess_expires_04b16a_idx'),
),
]
+172
View File
@@ -0,0 +1,172 @@
from __future__ import annotations
import hashlib
import hmac
import secrets
from django.conf import settings
from django.db import models
from django.utils import timezone
class ClientDevice(models.Model):
class ProductCode(models.TextChoices):
CMSHOPEE = "cmshopee", "虾皮圈优化助手"
class Platform(models.TextChoices):
WINDOWS = "windows", "Windows"
MACOS = "macos", "macOS"
LINUX = "linux", "Linux"
class Status(models.TextChoices):
ACTIVE = "active", "有效"
REVOKED = "revoked", "已吊销"
user = models.ForeignKey(
settings.AUTH_USER_MODEL,
verbose_name="用户",
on_delete=models.PROTECT,
related_name="client_devices",
)
product_code = models.CharField(
"产品代码",
max_length=32,
choices=ProductCode.choices,
)
device_id_version = models.CharField("设备标识版本", max_length=32)
device_fingerprint = models.CharField("设备摘要", max_length=64, editable=False)
public_key_fingerprint = models.CharField("安装公钥摘要", max_length=64, editable=False)
platform = models.CharField("平台", max_length=16, choices=Platform.choices)
client_version = models.CharField("客户端版本", max_length=64)
status = models.CharField(
"状态",
max_length=20,
choices=Status.choices,
default=Status.ACTIVE,
)
first_seen_at = models.DateTimeField("首次登记时间", auto_now_add=True)
last_seen_at = models.DateTimeField("最后活跃时间", auto_now_add=True)
created_at = models.DateTimeField("创建时间", auto_now_add=True)
updated_at = models.DateTimeField("更新时间", auto_now=True)
class Meta:
db_table = "client_device"
verbose_name = "客户端设备"
verbose_name_plural = "客户端设备"
ordering = ("-last_seen_at", "-id")
constraints = [
models.UniqueConstraint(
fields=("user", "product_code", "device_fingerprint"),
name="client_device_user_product_fingerprint_unique",
),
]
indexes = [
models.Index(fields=("user", "product_code", "status")),
models.Index(fields=("product_code", "last_seen_at")),
]
def __str__(self) -> str:
return f"{self.user} {self.product_code} {self.platform}"
@staticmethod
def fingerprint_device_id(device_id_version: str, device_id: str) -> str:
payload = f"{device_id_version}:{device_id}".encode("utf-8")
pepper = settings.DEVICE_IDENTIFIER_PEPPER.encode("utf-8")
return hmac.new(pepper, payload, hashlib.sha256).hexdigest()
@staticmethod
def fingerprint_public_key(public_key: str) -> str:
return hashlib.sha256(public_key.encode("utf-8")).hexdigest()
class DeviceSession(models.Model):
TOKEN_PREFIX_LENGTH = 12
device = models.ForeignKey(
ClientDevice,
verbose_name="客户端设备",
on_delete=models.PROTECT,
related_name="sessions",
)
token_hash = models.CharField("会话令牌哈希", max_length=64, unique=True, editable=False)
expires_at = models.DateTimeField("过期时间")
last_used_at = models.DateTimeField("最后使用时间", null=True, blank=True)
revoked_at = models.DateTimeField("吊销时间", null=True, blank=True)
created_at = models.DateTimeField("创建时间", auto_now_add=True)
class Meta:
db_table = "device_session"
verbose_name = "设备会话"
verbose_name_plural = "设备会话"
ordering = ("-created_at", "-id")
indexes = [
models.Index(fields=("device", "expires_at")),
models.Index(fields=("expires_at", "revoked_at")),
]
def __str__(self) -> str:
return f"{self.device} session"
@classmethod
def generate_plaintext_token(cls) -> str:
return f"dvs_cmhub_{secrets.token_urlsafe(32)}"
@staticmethod
def hash_token(raw_token: str) -> str:
return hashlib.sha256(raw_token.encode("utf-8")).hexdigest()
def matches_token(self, raw_token: str) -> bool:
return hmac.compare_digest(self.token_hash, self.hash_token(raw_token))
def is_active_at(self, now=None) -> bool:
now = now or timezone.now()
return self.revoked_at is None and self.expires_at > now
class DeviceBindingAudit(models.Model):
class Action(models.TextChoices):
REGISTERED = "registered", "已登记"
SESSION_ISSUED = "session_issued", "会话已签发"
HEARTBEAT = "heartbeat", "心跳"
REJECTED = "rejected", "请求被拒绝"
REVOKED = "revoked", "设备已吊销"
user = models.ForeignKey(
settings.AUTH_USER_MODEL,
verbose_name="用户",
on_delete=models.PROTECT,
related_name="device_binding_audits",
)
device = models.ForeignKey(
ClientDevice,
verbose_name="客户端设备",
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name="audits",
)
api_key = models.ForeignKey(
"users.ApiKey",
verbose_name="来源 API 密钥",
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name="device_binding_audits",
)
action = models.CharField("动作", max_length=32, choices=Action.choices)
reason = models.CharField("原因", max_length=120, blank=True)
client_version = models.CharField("客户端版本", max_length=64, blank=True)
created_at = models.DateTimeField("创建时间", auto_now_add=True)
class Meta:
db_table = "device_binding_audit"
verbose_name = "设备绑定审计"
verbose_name_plural = "设备绑定审计"
ordering = ("-created_at", "-id")
indexes = [
models.Index(fields=("user", "created_at")),
models.Index(fields=("device", "created_at")),
]
def __str__(self) -> str:
return f"{self.user} {self.action}"
+152
View File
@@ -0,0 +1,152 @@
from __future__ import annotations
from dataclasses import dataclass
from datetime import timedelta
from django.conf import settings
from django.db import IntegrityError, transaction
from django.utils import timezone
from apps.licensing.models import ClientDevice, DeviceBindingAudit, DeviceSession
class DeviceRegistrationError(Exception):
def __init__(self, code: str, message: str):
self.code = code
self.message = message
super().__init__(message)
@dataclass(frozen=True)
class DeviceRegistrationResult:
device: ClientDevice
session: DeviceSession
session_token: str
created: bool
def _session_expiry(now):
return now + timedelta(seconds=max(60, settings.DEVICE_SESSION_TTL_SECONDS))
def _find_or_create_device(*, user, product_code, device_id_version, device_id, public_key, platform, client_version, now):
device_fingerprint = ClientDevice.fingerprint_device_id(device_id_version, device_id)
public_key_fingerprint = ClientDevice.fingerprint_public_key(public_key)
lookup = {
"user": user,
"product_code": product_code,
"device_fingerprint": device_fingerprint,
}
device = ClientDevice.objects.select_for_update().filter(**lookup).first()
created = False
if device is None:
try:
with transaction.atomic():
device = ClientDevice.objects.create(
**lookup,
device_id_version=device_id_version,
public_key_fingerprint=public_key_fingerprint,
platform=platform,
client_version=client_version,
last_seen_at=now,
)
created = True
except IntegrityError:
device = ClientDevice.objects.select_for_update().get(**lookup)
if device.status != ClientDevice.Status.ACTIVE:
raise DeviceRegistrationError("device_revoked", "设备已被吊销")
if not device.public_key_fingerprint == public_key_fingerprint:
raise DeviceRegistrationError("device_identity_mismatch", "设备身份校验失败")
updates = []
if device.platform != platform:
device.platform = platform
updates.append("platform")
if device.client_version != client_version:
device.client_version = client_version
updates.append("client_version")
update_after = timedelta(seconds=max(60, settings.DEVICE_ACTIVITY_UPDATE_SECONDS))
if now - device.last_seen_at >= update_after:
device.last_seen_at = now
updates.append("last_seen_at")
if updates:
device.save(update_fields=[*updates, "updated_at"])
return device, created
@transaction.atomic
def register_device(*, user, api_key, product_code, device_id_version, device_id, public_key, platform, client_version):
now = timezone.now()
device, created = _find_or_create_device(
user=user,
product_code=product_code,
device_id_version=device_id_version,
device_id=device_id,
public_key=public_key,
platform=platform,
client_version=client_version,
now=now,
)
# Registration rotates the short-lived session while keeping one device row.
DeviceSession.objects.filter(
device=device,
revoked_at__isnull=True,
expires_at__gt=now,
).update(revoked_at=now)
raw_token = DeviceSession.generate_plaintext_token()
session = DeviceSession.objects.create(
device=device,
token_hash=DeviceSession.hash_token(raw_token),
expires_at=_session_expiry(now),
)
if created:
DeviceBindingAudit.objects.create(
user=user,
device=device,
api_key=api_key,
action=DeviceBindingAudit.Action.REGISTERED,
client_version=client_version,
)
DeviceBindingAudit.objects.create(
user=user,
device=device,
api_key=api_key,
action=DeviceBindingAudit.Action.SESSION_ISSUED,
client_version=client_version,
)
return DeviceRegistrationResult(
device=device,
session=session,
session_token=raw_token,
created=created,
)
@transaction.atomic
def record_device_heartbeat(session: DeviceSession, *, now=None) -> bool:
now = now or timezone.now()
session = (
DeviceSession.objects.select_for_update()
.select_related("device", "device__user")
.get(pk=session.pk)
)
if not session.is_active_at(now) or session.device.status != ClientDevice.Status.ACTIVE:
raise DeviceRegistrationError("device_revoked", "设备会话不可用")
update_after = timedelta(seconds=max(60, settings.DEVICE_ACTIVITY_UPDATE_SECONDS))
last_seen_at = session.device.last_seen_at
if now - last_seen_at < update_after:
return False
ClientDevice.objects.filter(pk=session.device_id).update(last_seen_at=now)
DeviceSession.objects.filter(pk=session.pk).update(last_used_at=now)
DeviceBindingAudit.objects.create(
user=session.device.user,
device=session.device,
action=DeviceBindingAudit.Action.HEARTBEAT,
client_version=session.device.client_version,
)
return True
+162
View File
@@ -0,0 +1,162 @@
from datetime import timedelta
from django.test import TestCase, override_settings
from django.urls import reverse
from django.utils import timezone
from rest_framework.test import APIClient
from apps.licensing.models import ClientDevice, DeviceBindingAudit, DeviceSession
from apps.licensing.services import record_device_heartbeat
from apps.users.models import ApiKey, User, UserWallet
class DeviceRegistrationApiTests(TestCase):
def setUp(self):
self.user = User.objects.create_user(
username="device-user",
email="device@example.com",
password="test-password",
)
UserWallet.objects.create(user=self.user, points_balance=20)
self.api_key, self.raw_api_key = ApiKey.create_for_user(self.user, name="desktop")
self.client = APIClient()
self.register_url = reverse("api-client-device-register")
self.heartbeat_url = reverse("api-client-device-heartbeat")
self.payload = {
"product_code": ClientDevice.ProductCode.CMSHOPEE,
"device_id": "v1:installed-device-abcdef",
"device_id_version": "v1",
"installation_public_key": "test-installation-public-key",
"platform": ClientDevice.Platform.WINDOWS,
"client_version": "0.1.0",
}
def register_device(self):
self.client.credentials(HTTP_AUTHORIZATION=f"Bearer {self.raw_api_key}")
return self.client.post(self.register_url, self.payload, format="json")
def test_register_requires_api_key(self):
response = self.client.post(self.register_url, self.payload, format="json")
self.assertEqual(response.status_code, 401)
self.assertEqual(response.data["error"]["code"], "unauthorized")
self.assertFalse(ClientDevice.objects.exists())
def test_register_creates_hashed_device_and_one_time_session_token(self):
response = self.register_device()
self.assertEqual(response.status_code, 201)
self.assertEqual(response.data["device"]["product_code"], "cmshopee")
self.assertIn("device_session_token", response.data)
self.assertNotIn("installation_public_key", response.data)
self.assertNotIn("device_id", response.data)
device = ClientDevice.objects.get(user=self.user)
session = DeviceSession.objects.get(device=device)
raw_token = response.data["device_session_token"]
self.assertNotEqual(device.device_fingerprint, self.payload["device_id"])
self.assertNotEqual(device.public_key_fingerprint, self.payload["installation_public_key"])
self.assertNotEqual(session.token_hash, raw_token)
self.assertTrue(session.matches_token(raw_token))
self.assertEqual(
DeviceBindingAudit.objects.filter(
device=device,
action=DeviceBindingAudit.Action.REGISTERED,
).count(),
1,
)
def test_repeat_registration_reuses_device_and_rotates_session(self):
first = self.register_device()
second = self.register_device()
self.assertEqual(first.status_code, 201)
self.assertEqual(second.status_code, 200)
self.assertEqual(ClientDevice.objects.filter(user=self.user).count(), 1)
device = ClientDevice.objects.get(user=self.user)
self.assertEqual(
DeviceSession.objects.filter(device=device, revoked_at__isnull=True).count(),
1,
)
self.assertEqual(DeviceSession.objects.filter(device=device).count(), 2)
self.assertNotEqual(
first.data["device_session_token"],
second.data["device_session_token"],
)
@override_settings(DEVICE_SESSION_TTL_SECONDS=172800)
def test_heartbeat_uses_device_session_and_activity_is_throttled(self):
registration = self.register_device()
token = registration.data["device_session_token"]
self.client.credentials(HTTP_X_DEVICE_SESSION=token)
response = self.client.post(self.heartbeat_url, {}, format="json")
self.assertEqual(response.status_code, 200)
self.assertFalse(response.data["activity_updated"])
device = ClientDevice.objects.get(user=self.user)
session = DeviceSession.objects.get(device=device, revoked_at__isnull=True)
updated = record_device_heartbeat(
session,
now=device.last_seen_at + timedelta(days=1, seconds=1),
)
self.assertTrue(updated)
device.refresh_from_db()
self.assertEqual(
DeviceBindingAudit.objects.filter(
device=device,
action=DeviceBindingAudit.Action.HEARTBEAT,
).count(),
1,
)
def test_invalid_or_revoked_device_session_is_rejected(self):
self.client.credentials(HTTP_X_DEVICE_SESSION="dvs_cmhub_invalid")
invalid = self.client.post(self.heartbeat_url, {}, format="json")
self.assertEqual(invalid.status_code, 401)
self.assertEqual(invalid.data["error"]["code"], "device_session_invalid")
registration = self.register_device()
device = ClientDevice.objects.get(user=self.user)
device.status = ClientDevice.Status.REVOKED
device.save(update_fields=("status",))
self.client.credentials(HTTP_X_DEVICE_SESSION=registration.data["device_session_token"])
revoked = self.client.post(self.heartbeat_url, {}, format="json")
self.assertEqual(revoked.status_code, 403)
self.assertEqual(revoked.data["error"]["code"], "device_revoked")
def test_existing_balance_api_remains_compatible_without_device_header(self):
self.client.credentials(HTTP_AUTHORIZATION=f"Bearer {self.raw_api_key}")
response = self.client.get(reverse("api-balance"))
self.assertEqual(response.status_code, 200)
self.assertEqual(response.data["points_balance"], 20)
@override_settings(DEVICE_ACTIVITY_UPDATE_SECONDS=60)
def test_heartbeat_updates_when_device_is_stale(self):
registration = self.register_device()
device = ClientDevice.objects.get(user=self.user)
stale_time = timezone.now() - timedelta(minutes=2)
ClientDevice.objects.filter(pk=device.pk).update(last_seen_at=stale_time)
self.client.credentials(HTTP_X_DEVICE_SESSION=registration.data["device_session_token"])
response = self.client.post(self.heartbeat_url, {}, format="json")
self.assertEqual(response.status_code, 200)
self.assertTrue(response.data["activity_updated"])
device.refresh_from_db()
self.assertGreater(device.last_seen_at, stale_time)
@override_settings(DEVICE_ACTIVITY_UPDATE_SECONDS=60)
def test_repeat_registration_updates_stale_device_activity(self):
self.register_device()
device = ClientDevice.objects.get(user=self.user)
stale_time = timezone.now() - timedelta(minutes=2)
ClientDevice.objects.filter(pk=device.pk).update(last_seen_at=stale_time)
response = self.register_device()
self.assertEqual(response.status_code, 200)
device.refresh_from_db()
self.assertGreater(device.last_seen_at, stale_time)