Files
chunyu_project/user/views/email.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

194 lines
7.9 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
from rest_framework import status
from adrf.views import APIView
from rest_framework.permissions import IsAuthenticated
from asgiref.sync import sync_to_async
from drf_yasg.utils import swagger_auto_schema
from drf_yasg import openapi
import logging
from chunyu_project.common_schemas import success_response, error_response, unauthorized_response, not_found_response
from utils.response_codes import (
ResponseCode,
create_standardized_response,
create_standardized_error_response
)
from utils.email_utils import validate_email_mx
from ..serializers.user_serializers import (
SendEmailCodeSerializer,
ChangeEmailSerializer,
UserSerializer,
)
logger = logging.getLogger(__name__)
class SendChangeEmailCodeAPIView(APIView):
permission_classes = [IsAuthenticated]
@swagger_auto_schema(
tags=['用户'],
operation_summary='发送修改邮箱验证码',
operation_description='向新邮箱发送验证码用于修改邮箱',
request_body=openapi.Schema(
type=openapi.TYPE_OBJECT,
properties={
'email': openapi.Schema(type=openapi.TYPE_STRING, description='新邮箱地址'),
'captcha_key': openapi.Schema(type=openapi.TYPE_STRING, description='图形验证码 key'),
'captcha_code': openapi.Schema(type=openapi.TYPE_STRING, description='图形验证码'),
},
),
responses={200: success_response, 400: error_response, 401: unauthorized_response, 500: error_response},
)
async def post(self, request):
from utils.captcha import check_captcha_required, verify_captcha, record_failure, reset_failures
identifier = str(request.user.id)
operation = 'change_email'
from utils.rate_limit import check_rate_limit
if not await sync_to_async(check_rate_limit)('change_email_send', identifier, limit=1, window_seconds=60):
return create_standardized_error_response(
code=ResponseCode.PARAMETER_ERROR,
message="验证码发送过于频繁,请60秒后再试",
status_code=status.HTTP_429_TOO_MANY_REQUESTS
)
captcha_required = await sync_to_async(check_captcha_required)(operation, identifier)
if captcha_required:
captcha_key = request.data.get('captcha_key', None)
captcha_code = request.data.get('captcha_code', None)
if not captcha_key or not captcha_code:
return create_standardized_error_response(
code=ResponseCode.CAPTCHA_REQUIRED,
status_code=status.HTTP_400_BAD_REQUEST
)
captcha_result = await sync_to_async(verify_captcha)(captcha_key, captcha_code)
if captcha_result == 'expired':
return create_standardized_error_response(
code=ResponseCode.CAPTCHA_EXPIRED,
status_code=status.HTTP_400_BAD_REQUEST
)
elif captcha_result == 'wrong':
await sync_to_async(record_failure)(operation, identifier)
return create_standardized_error_response(
code=ResponseCode.CAPTCHA_ERROR,
status_code=status.HTTP_400_BAD_REQUEST
)
serializer = SendEmailCodeSerializer(
data=request.data,
context={'request': request}
)
# 校验器内含同步 ORM(validate_email 查重)与 cache 写入,线程池兜底
if not await sync_to_async(serializer.is_valid)():
errors = serializer.errors
first_error = ''
for field, msgs in errors.items():
if isinstance(msgs, list) and msgs:
first_error = str(msgs[0])
break
await sync_to_async(record_failure)(operation, identifier)
return create_standardized_error_response(
data=errors,
code=ResponseCode.PARAMETER_ERROR,
message=first_error or '参数异常',
status_code=status.HTTP_400_BAD_REQUEST
)
email = serializer.validated_data.get('email')
if email and not await sync_to_async(validate_email_mx)(email):
logger.warning(f'[ChangeEmail] Domain MX check failed: email={email}')
await sync_to_async(record_failure)(operation, identifier)
return create_standardized_error_response(
code=ResponseCode.EMAIL_DOMAIN_INVALID,
status_code=status.HTTP_400_BAD_REQUEST
)
try:
# save() 内部触发验证码邮件发送(Celery/cache/SMTP 链路),线程池兜底
email = await serializer.asave()
await sync_to_async(reset_failures)(operation, identifier)
return create_standardized_response(
data={'email_sent': True},
code=ResponseCode.EMAIL_CHANGE_CODE_SENT,
status_code=status.HTTP_200_OK
)
except Exception as e:
await sync_to_async(record_failure)(operation, identifier)
return create_standardized_error_response(
message=f'邮件发送失败: {str(e)}',
code=ResponseCode.EMAIL_SEND_FAILED,
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR
)
class ChangeEmailAPIView(APIView):
permission_classes = [IsAuthenticated]
@swagger_auto_schema(
tags=['用户'],
operation_summary='修改邮箱',
operation_description='使用邮箱验证码修改用户邮箱',
request_body=openapi.Schema(
type=openapi.TYPE_OBJECT,
properties={
'email': openapi.Schema(type=openapi.TYPE_STRING, description='新邮箱地址'),
'email_code': openapi.Schema(type=openapi.TYPE_STRING, description='邮箱验证码'),
},
),
responses={200: success_response, 400: error_response, 401: unauthorized_response, 500: error_response},
)
async def post(self, request):
serializer = ChangeEmailSerializer(
data=request.data,
context={'request': request}
)
# 校验器内含同步 ORM 查询,线程池兜底
if not await sync_to_async(serializer.is_valid)():
errors = serializer.errors
first_error = ''
for field, msgs in errors.items():
if isinstance(msgs, list) and msgs:
first_error = str(msgs[0])
break
return create_standardized_error_response(
data=errors,
code=ResponseCode.PARAMETER_ERROR,
message=first_error or '参数异常',
status_code=status.HTTP_400_BAD_REQUEST
)
# 安全修复:此前从未调用 avalidate,邮箱验证码形同虚设(任何已登录用户可无码改绑邮箱)
try:
await serializer.avalidate(serializer.validated_data)
except Exception as e:
return create_standardized_error_response(
data=getattr(e, 'detail', None) or {'code': [str(e)]},
code=ResponseCode.PARAMETER_ERROR,
message='验证码校验失败',
status_code=status.HTTP_400_BAD_REQUEST
)
try:
updated_user = await serializer.asave()
user_serializer = UserSerializer(updated_user)
return create_standardized_response(
data={'user': await user_serializer.adata},
code=ResponseCode.EMAIL_CHANGED,
status_code=status.HTTP_200_OK
)
except Exception as e:
return create_standardized_error_response(
message=f'邮箱修改失败: {str(e)}',
code=ResponseCode.SERVER_INTERNAL_ERROR,
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR
)