Files
chunyu_project/user/views/qr_login.py
T
root 3618323192 fix(security): P1/P2 审计修复 + JWT HttpOnly Cookie 双模认证 + 限流
- P0/P1 审计修复: 滑块验证码不再下发 x_position/成败即销毁 key、
  user-login 补失败计数+滑块门控、限流标识改 X-Real-IP、
  百度翻译 appkey 环境化、ChangeEmail/ChangePhone 补调 avalidate、
  logs/tasks.py Count(filter=Q) 修复、chat 收藏 SSRF 内网黑名单
- P1 #6/7: token_blacklist + ROTATE_REFRESH_TOKENS 开启,
  /user/token/refresh/ 挂载
- #2 JWT HttpOnly Cookie 双模认证: user/cookie_auth.py 种/清 Cookie,
  user/authentication.py CookieOrHeaderJWTAuthentication(Bearer 优先/_COOKIE 兜底),
  user/views/token.py CookieTokenRefreshView + UserLogoutAPIView(/user/logout/),
  create_standardized_response 自动对含 token 的响应种 Cookie,
  异步视图内 RefreshToken.for_user 全部 sync_to_async 包裹(修 SynchronousOnlyOperation 500),
  WS ChatConsumer 优先读 Cookie token
- P2 #11 限流: utils/rate_limit.py 固定窗口频控,
  shorturl 生成 匿名10次/分+登录60次/分, 邮箱验证码 同邮箱60s1次+同IP10次/10min,
  登录/注册验证码 错5次作废+成功即销毁防重放, 换绑邮箱/手机 同步落地,
  urls.py 补挂 shorturl 路由(此前 404)
2026-09-08 11:28:00 +08:00

164 lines
6.3 KiB
Python

from adrf.views import APIView
from rest_framework.permissions import AllowAny, IsAuthenticated
from rest_framework import status
from asgiref.sync import sync_to_async
from drf_yasg.utils import swagger_auto_schema
from drf_yasg import openapi
from user.qr_auth import generate_qr_token, get_qr_status, scan_qr, confirm_qr, cancel_qr
from user.models import FUser
from user.serializers.user_serializers import UserSerializer
from user.services import create_login_record
from utils.response_codes import ResponseCode, create_standardized_response, create_standardized_error_response
from rest_framework_simplejwt.tokens import RefreshToken
from chunyu_project.common_schemas import success_response, error_response
class QRTokenView(APIView):
permission_classes = [AllowAny]
@swagger_auto_schema(
tags=['认证'],
operation_summary='获取扫码登录二维码',
operation_description='生成唯一的 QR Token,返回 token 供前端生成二维码',
responses={200: success_response},
)
async def post(self, request):
token = await sync_to_async(generate_qr_token)()
return create_standardized_response(data={
"token": token,
"expires_in": 300,
})
class QRStatusView(APIView):
permission_classes = [AllowAny]
@swagger_auto_schema(
tags=['认证'],
operation_summary='查询扫码状态',
operation_description='前端轮询此接口获取扫码登录状态',
manual_parameters=[
openapi.Parameter('token', openapi.IN_PATH, description='QR Token', type=openapi.TYPE_STRING, required=True),
],
responses={200: success_response},
)
async def get(self, request, token):
# get_qr_status 走同步 cache,线程池兜底
data = await sync_to_async(get_qr_status)(token)
response_data = {
"status": data.get("status", "expired"),
"username": data.get("scan_username"),
}
if data.get("status") == "confirmed":
scan_user_id = data.get("scan_user_id")
user = await FUser.objects.filter(id=scan_user_id).afirst()
if user:
refresh = await sync_to_async(RefreshToken.for_user)(user)
response_data["auth"] = {
"user": await sync_to_async(lambda: UserSerializer(user).data)(),
"refresh": str(refresh),
"access": str(refresh.access_token),
"token_type": "bearer",
"expires_at_timestamp": refresh.access_token.payload['exp'],
}
return create_standardized_response(data=response_data)
class QRScanView(APIView):
permission_classes = [IsAuthenticated]
@swagger_auto_schema(
tags=['认证'],
operation_summary='APP扫码上报',
operation_description='APP扫描WEB端二维码后,上报token和用户信息',
request_body=openapi.Schema(
type=openapi.TYPE_OBJECT,
required=['token'],
properties={
'token': openapi.Schema(type=openapi.TYPE_STRING, description='扫描到的QR Token'),
},
),
responses={200: success_response, 400: error_response, 401: error_response},
)
async def post(self, request):
token = request.data.get("token")
if not token:
return create_standardized_error_response(
code=ResponseCode.PARAMETER_ERROR,
message="缺少 token",
status_code=status.HTTP_400_BAD_REQUEST,
)
user = request.user
# scan_qr 走同步 cache,线程池兜底
success = await sync_to_async(scan_qr)(token, user.id, user.username)
if not success:
return create_standardized_error_response(
code=ResponseCode.VALIDATION_ERROR,
message="二维码已过期或无效",
status_code=status.HTTP_400_BAD_REQUEST,
)
return create_standardized_response(message="扫码成功,等待确认")
class QRConfirmView(APIView):
permission_classes = [IsAuthenticated]
@swagger_auto_schema(
tags=['认证'],
operation_summary='APP确认登录',
operation_description='APP端用户确认登录WEB端',
request_body=openapi.Schema(
type=openapi.TYPE_OBJECT,
required=['token'],
properties={
'token': openapi.Schema(type=openapi.TYPE_STRING, description='QR Token'),
},
),
responses={200: success_response, 400: error_response, 401: error_response},
)
async def post(self, request):
token = request.data.get("token")
if not token:
return create_standardized_error_response(
code=ResponseCode.PARAMETER_ERROR,
message="缺少 token",
status_code=status.HTTP_400_BAD_REQUEST,
)
# confirm_qr 走同步 cache,线程池兜底
data = await sync_to_async(confirm_qr)(token)
if not data:
return create_standardized_error_response(
code=ResponseCode.VALIDATION_ERROR,
message="确认失败,请重新扫码",
status_code=status.HTTP_400_BAD_REQUEST,
)
# create_login_record 内含 ORM 写入,线程池兜底
await sync_to_async(create_login_record)(request, request.user, 'success')
return create_standardized_response(message="登录确认成功")
class QRCancelView(APIView):
permission_classes = [IsAuthenticated]
@swagger_auto_schema(
tags=['认证'],
operation_summary='APP取消登录',
operation_description='APP端用户取消登录WEB端',
request_body=openapi.Schema(
type=openapi.TYPE_OBJECT,
properties={
'token': openapi.Schema(type=openapi.TYPE_STRING, description='QR Token'),
},
),
responses={200: success_response, 401: error_response},
)
async def post(self, request):
token = request.data.get("token")
if token:
# cancel_qr 走同步 cache,线程池兜底
await sync_to_async(cancel_qr)(token)
return create_standardized_response(message="已取消")