feat: add device registration observation
This commit is contained in:
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -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
|
||||
@@ -0,0 +1,7 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class LicensingConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "apps.licensing"
|
||||
verbose_name = "软件授权"
|
||||
@@ -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"
|
||||
@@ -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'),
|
||||
),
|
||||
]
|
||||
@@ -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}"
|
||||
@@ -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
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user