From 8340be7eb0da05042189c35faf00fc927ec006c2 Mon Sep 17 00:00:00 2001 From: TermiNexus Date: Fri, 14 Aug 2026 00:33:50 +0800 Subject: [PATCH] =?UTF-8?q?feat(msync):=20UserTokenAuthentication=20?= =?UTF-8?q?=E7=94=A8=E6=88=B7=E4=BB=A4=E7=89=8C=E8=87=AA=E7=9C=81=20+=20/m?= =?UTF-8?q?e=20=E7=AB=AF=E7=82=B9=EF=BC=88iframe=20=E8=BA=AB=E4=BB=BD?= =?UTF-8?q?=E9=80=8F=E4=BC=A0=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ATestCServer/apps/sub_server/service_auth.py | 90 ++++++++++++++++---- ATestCServer/apps/sub_server/urls.py | 1 + ATestCServer/apps/sub_server/views.py | 22 ++++- 3 files changed, 95 insertions(+), 18 deletions(-) diff --git a/ATestCServer/apps/sub_server/service_auth.py b/ATestCServer/apps/sub_server/service_auth.py index c9c7c307..d74e560a 100644 --- a/ATestCServer/apps/sub_server/service_auth.py +++ b/ATestCServer/apps/sub_server/service_auth.py @@ -36,6 +36,50 @@ class ServicePrincipal: return f'Service:{self.service_name}' +class UserPrincipal: + """用户主体——员工 iframe 透传的用户上下文(用户令牌自省结果)""" + + def __init__(self, payload): + payload = payload or {} + self.UserUUID = payload.get('UserUUID') + self.RoleUUID = payload.get('RoleUUID') + self.CompanyUUID = payload.get('CompanyUUID') + self.TenantUUID = payload.get('TenantUUID') + + @property + def is_authenticated(self): + return True + + def __str__(self): + return f'User:{self.UserUUID}' + + +USER_TOKEN_INTROSPECT_PATH = '/api/msync/user/token/introspect/' + + +def _RemoteIntrospect(path, body): + """远程自省主服务器令牌端点""" + master_url = str(getattr(settings, 'MSYNC_MASTER_URL', '')).rstrip('/') + if not master_url: + return None + data = json.dumps(body).encode('utf-8') + req = urllib.request.Request( + f'{master_url}{path}', + data=data, + headers={'Content-Type': 'application/json'}, + method='POST', + ) + try: + with urllib.request.urlopen(req, timeout=10) as resp: + result = json.loads(resp.read().decode('utf-8')) + except (urllib.error.URLError, urllib.error.HTTPError, ValueError, OSError) as e: + logger.error(f'令牌自省失败: {e}') + return None + if not result.get('Valid'): + return None + return result.get('Payload') or {} + + class SubServerServiceAuthentication(BaseAuthentication): """DRF 认证类——校验主服务器 Service 令牌(远程自省)""" @@ -59,23 +103,35 @@ class SubServerServiceAuthentication(BaseAuthentication): @staticmethod def _Introspect(access_token): """调用主服务器令牌自省端点""" - master_url = str(getattr(settings, 'MSYNC_MASTER_URL', '')).rstrip('/') - if not master_url: + return _RemoteIntrospect(INTROSPECT_PATH, {'AccessToken': access_token}) + + +class UserTokenAuthentication(BaseAuthentication): + """DRF 认证类——校验员工用户级令牌(iframe 跨服务器身份透传,远程自省) + + Authorization: Bearer + 入参附带 AudienceService=本服务名,主服务器校验 aud 防令牌串用。 + 认证通过后 request.user 为 UserPrincipal(含 UserUUID/RoleUUID/ + CompanyUUID/TenantUUID),子服务器业务 API 按此鉴权。 + """ + + def authenticate(self, request): + auth_header = request.headers.get(SERVICE_AUTH_HEADER, '') + if not auth_header.startswith('Bearer '): return None - body = json.dumps({'AccessToken': access_token}).encode('utf-8') - req = urllib.request.Request( - f'{master_url}{INTROSPECT_PATH}', - data=body, - headers={'Content-Type': 'application/json'}, - method='POST', - ) - try: - with urllib.request.urlopen(req, timeout=10) as resp: - data = json.loads(resp.read().decode('utf-8')) - except (urllib.error.URLError, urllib.error.HTTPError, ValueError, OSError) as e: - logger.error(f'令牌自省失败: {e}') + token = auth_header[len('Bearer '):].strip() + if not token: return None - if not data.get('Valid'): - return None - return data.get('Payload') or {} + audience = getattr(settings, 'MSYNC_SERVICE_NAME', '') + payload = _RemoteIntrospect( + USER_TOKEN_INTROSPECT_PATH, + {'AccessToken': token, 'AudienceService': audience}, + ) + if not payload: + raise AuthenticationFailed('用户令牌验证失败') + + return (UserPrincipal(payload), token) + + def authenticate_header(self, request): + return 'Bearer' diff --git a/ATestCServer/apps/sub_server/urls.py b/ATestCServer/apps/sub_server/urls.py index d3a8bb62..46a5642c 100644 --- a/ATestCServer/apps/sub_server/urls.py +++ b/ATestCServer/apps/sub_server/urls.py @@ -6,6 +6,7 @@ app_name = 'sub_server' urlpatterns = [ path('health/', views.HealthView.as_view(), name='health'), path('status/', views.StatusPageView, name='status_page'), + path('me/', views.UserMeView.as_view(), name='user_me'), path('msync/status/', views.MSyncStatusView.as_view(), name='msync_status'), path('users/', views.UserListView.as_view(), name='user_list'), path('users//', views.UserDetailView.as_view(), name='user_detail'), diff --git a/ATestCServer/apps/sub_server/views.py b/ATestCServer/apps/sub_server/views.py index 9b6389ca..d353638f 100644 --- a/ATestCServer/apps/sub_server/views.py +++ b/ATestCServer/apps/sub_server/views.py @@ -7,7 +7,7 @@ from django.shortcuts import render from rest_framework.views import APIView from rest_framework.response import Response from rest_framework.permissions import AllowAny, IsAuthenticated -from .service_auth import SubServerServiceAuthentication +from .service_auth import SubServerServiceAuthentication, UserTokenAuthentication from .models import BusinessUser, BusinessOrder, SyncLog from .serializers import ( BusinessUserSerializer, @@ -341,6 +341,26 @@ def StatusPageView(request): }) +class UserMeView(APIView): + """当前用户上下文——员工 iframe 访问(用户令牌透传验证) + + Authorization: Bearer + 返回主服务器透传的用户/角色/公司/租户上下文。 + """ + + authentication_classes = [UserTokenAuthentication] + permission_classes = [IsAuthenticated] + + def get(self, request): + user = request.user + return Response(_ok({ + 'UserUUID': getattr(user, 'UserUUID', None), + 'RoleUUID': getattr(user, 'RoleUUID', None), + 'CompanyUUID': getattr(user, 'CompanyUUID', None), + 'TenantUUID': getattr(user, 'TenantUUID', None), + })) + + class LocalExecuteView(APIView): """本地事务执行端点——由主服务器 Saga 编排引擎调用(需主服务器 Service 令牌)"""