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)
This commit is contained in:
2026-09-08 11:28:00 +08:00
parent 2944b19e6f
commit 3618323192
22 changed files with 711 additions and 37 deletions
+20
View File
@@ -44,6 +44,15 @@ class SendChangeEmailCodeAPIView(APIView):
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:
@@ -156,6 +165,17 @@ class ChangeEmailAPIView(APIView):
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)
+77 -1
View File
@@ -9,7 +9,7 @@ from learn.models import Course, CourseFavorite
from apidirectory.models import ApiItem, ApiFavorite
from drf_yasg.utils import swagger_auto_schema
from drf_yasg import openapi
from chunyu_project.common_schemas import success_response, error_response, unauthorized_response
from chunyu_project.common_schemas import success_response, error_response, unauthorized_response, not_found_response
class MyFavoritesView(APIView):
@@ -114,3 +114,79 @@ class MyFavoritesView(APIView):
data={'results': results, 'counts': counts},
code=ResponseCode.SUCCESS
)
class FavoriteToggleView(APIView):
"""统一收藏切换端点:按 type + id 切换 tool/article/course/api 收藏状态。
修复:Android 端 FavoritesApi.toggleFavorite 调用的 user/favorites/toggle/
此前不存在(404)。响应契约与客户端对齐:{is_favorited, favorites_count}。
"""
permission_classes = [IsAuthenticated]
@swagger_auto_schema(
tags=['收藏'],
operation_summary='切换收藏状态',
operation_description='按类型切换收藏(tool/article/course/api),返回切换后的状态',
request_body=openapi.Schema(
type=openapi.TYPE_OBJECT,
required=['type', 'target_id'],
properties={
'type': openapi.Schema(type=openapi.TYPE_STRING, enum=['tool', 'article', 'course', 'api']),
'target_id': openapi.Schema(type=openapi.TYPE_INTEGER, description='目标对象 ID'),
},
),
responses={200: success_response, 400: error_response, 401: unauthorized_response, 404: not_found_response},
)
async def post(self, request):
fav_type = request.data.get('type')
target_id = request.data.get('target_id')
if fav_type not in ('tool', 'article', 'course', 'api'):
return create_standardized_error_response(
message='type 必须为 tool/article/course/api 之一',
code=ResponseCode.PARAMETER_ERROR,
status_code=status.HTTP_400_BAD_REQUEST
)
try:
target_id = int(target_id)
except (TypeError, ValueError):
return create_standardized_error_response(
message='target_id 必须为整数',
code=ResponseCode.PARAMETER_ERROR,
status_code=status.HTTP_400_BAD_REQUEST
)
# (模型, FK 字段名, 目标模型)
mapping = {
'tool': (ToolFavorite, 'tool', Tool),
'article': (ArticleFavorite, 'article', Article),
'course': (CourseFavorite, 'course', Course),
'api': (ApiFavorite, 'api_item', ApiItem),
}
model, fk_field, target_model = mapping[fav_type]
try:
await target_model.objects.aget(id=target_id)
except target_model.DoesNotExist:
return create_standardized_error_response(
message='目标对象不存在',
code=ResponseCode.PARAMETER_ERROR,
status_code=status.HTTP_404_NOT_FOUND
)
existing = await model.objects.filter(user=request.user, **{fk_field: target_id}).afirst()
if existing is not None:
await model.objects.filter(user=request.user, **{fk_field: target_id}).adelete()
favorited = False
else:
await model.objects.acreate(user=request.user, **{fk_field: target_id})
favorited = True
favorites_count = await model.objects.filter(user=request.user).acount()
return create_standardized_response(
data={'is_favorited': favorited, 'favorites_count': favorites_count, 'type': fav_type, 'target_id': target_id},
code=ResponseCode.SUCCESS
)
+20
View File
@@ -19,6 +19,15 @@ class SendPhoneCodeAPIView(APIView):
permission_classes = [IsAuthenticated]
async def post(self, request):
from utils.rate_limit import check_rate_limit
identifier = str(request.user.id)
if not await sync_to_async(check_rate_limit)('change_phone_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
)
serializer = SendPhoneCodeSerializer(
data=request.data,
context={'request': request}
@@ -80,6 +89,17 @@ class ChangePhoneAPIView(APIView):
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)
+2 -2
View File
@@ -55,9 +55,9 @@ class QRStatusView(APIView):
scan_user_id = data.get("scan_user_id")
user = await FUser.objects.filter(id=scan_user_id).afirst()
if user:
refresh = RefreshToken.for_user(user)
refresh = await sync_to_async(RefreshToken.for_user)(user)
response_data["auth"] = {
"user": UserSerializer(user).data,
"user": await sync_to_async(lambda: UserSerializer(user).data)(),
"refresh": str(refresh),
"access": str(refresh.access_token),
"token_type": "bearer",
+68
View File
@@ -0,0 +1,68 @@
from rest_framework_simplejwt.views import TokenRefreshView
from rest_framework_simplejwt.tokens import RefreshToken
from rest_framework.response import Response
from rest_framework import status
from adrf.views import APIView
from rest_framework.permissions import AllowAny
from asgiref.sync import sync_to_async
from drf_yasg.utils import swagger_auto_schema
from chunyu_project.common_schemas import success_response
from utils.response_codes import create_standardized_response
from ..cookie_auth import set_auth_cookies, clear_auth_cookies
class CookieTokenRefreshView(TokenRefreshView):
"""
双模 Token 刷新端点:
- 支持从请求体 { refresh: '...' } 获取(移动端/API)
- 也支持从 HttpOnly Cookie 获取 refresh_token(Web 端)
- 刷新成功后,自动将新 access 与 refresh 写入 HttpOnly Cookie
"""
def post(self, request, *args, **kwargs):
# 若请求体中未传递 refresh,尝试从 Cookie 自动填充
has_refresh_in_body = bool(request.data.get('refresh')) if hasattr(request, 'data') and request.data else False
if not has_refresh_in_body and 'refresh_token' in request.COOKIES:
data = request.data.copy() if hasattr(request.data, 'copy') else dict(request.data or {})
data['refresh'] = request.COOKIES['refresh_token']
request._full_data = data
response = super().post(request, *args, **kwargs)
if response.status_code == status.HTTP_200_OK and isinstance(response.data, dict):
access = response.data.get('access')
refresh = response.data.get('refresh')
set_auth_cookies(response, access_token=access, refresh_token=refresh)
return response
class UserLogoutAPIView(APIView):
"""
用户登出端点:清除客户端 HttpOnly Cookie,并将 refresh token 放入黑名单(若有)
"""
permission_classes = [AllowAny]
@swagger_auto_schema(
tags=['认证'],
operation_summary='退出登录',
operation_description='清除 HttpOnly Cookie 并拉黑 refresh token',
responses={200: success_response},
)
async def post(self, request):
refresh = request.data.get('refresh') if hasattr(request, 'data') and request.data else None
if not refresh:
refresh = request.COOKIES.get('refresh_token')
if refresh:
def _blacklist(r):
try:
RefreshToken(r).blacklist()
except Exception:
pass
await sync_to_async(_blacklist)(refresh)
response = create_standardized_response(
data={'logged_out': True},
message='退出成功',
status_code=status.HTTP_200_OK
)
clear_auth_cookies(response)
return response
+116 -10
View File
@@ -28,6 +28,12 @@ from .tasks import track_user_action
from utils.captcha import check_captcha_required, record_failure, reset_failures
from utils.slider_captcha import verify_slider_captcha, SliderCaptchaError
from utils.safe_task import submit_task
from utils.rate_limit import (
check_rate_limit,
record_failure as rl_record_failure,
reset_failures as rl_reset_failures,
get_client_ip,
)
from utils.response_codes import (
ResponseCode,
create_standardized_response,
@@ -41,10 +47,13 @@ from chunyu_project.common_schemas import success_response, error_response, unau
def _get_client_ip(request):
real_ip = request.META.get('HTTP_X_REAL_IP')
if real_ip:
return real_ip.strip()
x_forwarded_for = request.META.get('HTTP_X_FORWARDED_FOR')
if x_forwarded_for:
return x_forwarded_for.split(',')[0].strip()
return request.META.get('REMOTE_ADDR', '')
return request.META.get('REMOTE_ADDR', '').strip()
def _create_login_record(request, user, record_status):
@@ -82,6 +91,23 @@ class SendUserEmailAPIView(APIView):
status_code=status.HTTP_400_BAD_REQUEST
)
# 频控:同邮箱 60 秒内只能发送 1 次
if not await sync_to_async(check_rate_limit)('email_send_target', to_email.lower(), limit=1, window_seconds=60):
return create_standardized_error_response(
code=ResponseCode.PARAMETER_ERROR,
message="验证码发送过于频繁,请60秒后再试",
status_code=status.HTTP_429_TOO_MANY_REQUESTS
)
# 频控:同 IP 10 分钟内最多发送 10 次
client_ip = _get_client_ip(request)
if client_ip and not await sync_to_async(check_rate_limit)('email_send_ip', client_ip, limit=10, window_seconds=600):
return create_standardized_error_response(
code=ResponseCode.PARAMETER_ERROR,
message="请求过于频繁,请稍后再试",
status_code=status.HTTP_429_TOO_MANY_REQUESTS
)
if not await sync_to_async(validate_email_mx)(to_email):
logger.warning(f'[Email] Domain MX check failed: email={to_email}')
return create_standardized_error_response(
@@ -172,10 +198,14 @@ class UserLoginOrRegisterAPIView(APIView):
)
if code == vcode:
# 安全加固:验证码成即销毁,防重放攻击
await adelete_cache(f"register_{to_email}")
await sync_to_async(rl_reset_failures)('reg_vcode', to_email.lower())
user_serializer = UserSerializer(data=request.data)
if await sync_to_async(user_serializer.is_valid)():
user = await user_serializer.acreate_by_email(request.data)
refresh = RefreshToken.for_user(user)
refresh = await sync_to_async(RefreshToken.for_user)(user)
user_data = await UserSerializer(user).adata
# Prepare response data
@@ -200,8 +230,18 @@ class UserLoginOrRegisterAPIView(APIView):
status_code=status.HTTP_400_BAD_REQUEST
)
else:
# 安全加固:验证码错误累计计数,5 次即直接销毁,杜绝穷举爆破
fails = await sync_to_async(rl_record_failure)('reg_vcode', to_email.lower(), max_failures=5, window_seconds=300)
if fails >= 5:
await adelete_cache(f"register_{to_email}")
return create_standardized_error_response(
code=ResponseCode.VERIFICATION_CODE_EXPIRED,
message="验证码错误次数超限,已作废,请重新获取",
status_code=status.HTTP_400_BAD_REQUEST
)
return create_standardized_error_response(
code=ResponseCode.VERIFICATION_CODE_ERROR,
message=f"验证码错误,还剩 {5 - fails} 次尝试机会",
status_code=status.HTTP_400_BAD_REQUEST
)
else:
@@ -215,7 +255,11 @@ class UserLoginOrRegisterAPIView(APIView):
)
if code == vcode:
refresh = RefreshToken.for_user(user)
# 安全加固:验证码成即销毁,防重放攻击
await adelete_cache(f"login_{to_email}")
await sync_to_async(rl_reset_failures)('login_vcode', to_email.lower())
refresh = await sync_to_async(RefreshToken.for_user)(user)
user_data = await UserSerializer(user).adata
# Prepare response data
@@ -236,9 +280,18 @@ class UserLoginOrRegisterAPIView(APIView):
)
else:
await sync_to_async(_create_login_record)(request, user, 'failed')
# 安全加固:验证码错误累计计数,5 次即直接销毁,杜绝穷举爆破
fails = await sync_to_async(rl_record_failure)('login_vcode', to_email.lower(), max_failures=5, window_seconds=300)
if fails >= 5:
await adelete_cache(f"login_{to_email}")
return create_standardized_error_response(
code=ResponseCode.LOGIN_VERIFICATION_EXPIRED,
message="验证码错误次数超限,已作废,请重新获取",
status_code=status.HTTP_400_BAD_REQUEST
)
return create_standardized_error_response(
code=ResponseCode.LOGIN_VERIFICATION_ERROR,
message=f"验证码错误,还剩 {5 - fails} 次尝试机会",
status_code=status.HTTP_400_BAD_REQUEST
)
@@ -279,6 +332,14 @@ class ForgotPasswordSendCodeAPIView(APIView):
status_code=status.HTTP_400_BAD_REQUEST
)
# 频控:同邮箱 60 秒内只能发送 1 次
if not await sync_to_async(check_rate_limit)('forgot_send', to_email.lower(), limit=1, window_seconds=60):
return create_standardized_error_response(
code=ResponseCode.PARAMETER_ERROR,
message="验证码发送过于频繁,请60秒后再试",
status_code=status.HTTP_429_TOO_MANY_REQUESTS
)
if not await sync_to_async(validate_email_mx)(to_email):
return create_standardized_error_response(
code=ResponseCode.EMAIL_DOMAIN_INVALID,
@@ -652,16 +713,26 @@ class ChangePasswordAPIView(APIView):
class UserLoginAPIView(APIView):
permission_classes = [AllowAny]
def _get_identifier(self, request, account=''):
"""安全修复:优先取 nginx 覆写设置的 X-Real-IP(客户端伪造的 X-Forwarded-For
会被我们网关覆盖),并叠加账号维度,防止单一维度被绕过/恶意锁号。"""
real_ip = request.META.get('HTTP_X_REAL_IP') or request.META.get('REMOTE_ADDR') or 'unknown'
if account:
return f"{real_ip}:{account}"
return str(real_ip)
@swagger_auto_schema(
tags=['认证'],
operation_summary='账号密码登录',
operation_description='使用账号和密码进行登录,返回JWT token',
operation_description='使用账号和密码进行登录,失败次数过多需通过滑块验证码验证',
request_body=openapi.Schema(
type=openapi.TYPE_OBJECT,
required=['account', 'password'],
properties={
'account': openapi.Schema(type=openapi.TYPE_STRING, description='账号(用户名或邮箱)'),
'password': openapi.Schema(type=openapi.TYPE_STRING, description='密码'),
'slider_captcha_key': openapi.Schema(type=openapi.TYPE_STRING, description='滑块验证码key(需要时必填)'),
'slider_captcha_x': openapi.Schema(type=openapi.TYPE_INTEGER, description='滑块X坐标(需要时必填)'),
}
),
responses={200: success_response, 400: error_response, 401: unauthorized_response, 403: error_response},
@@ -677,12 +748,43 @@ class UserLoginAPIView(APIView):
status_code=status.HTTP_400_BAD_REQUEST
)
# 安全修复:与 LoginView 一致的失败计数 + 滑块验证码门控,
# 此前该端点无任何防爆破机制,攻击者可绕过 /user/login/ 无限暴力破解
identifier = self._get_identifier(request, account)
operation = 'login'
captcha_required = await sync_to_async(check_captcha_required)(operation, identifier)
if captcha_required:
slider_captcha_key = request.data.get('slider_captcha_key', None)
slider_captcha_x = request.data.get('slider_captcha_x', None)
if not slider_captcha_key or slider_captcha_x is None:
return create_standardized_error_response(
code=ResponseCode.CAPTCHA_REQUIRED,
status_code=status.HTTP_400_BAD_REQUEST
)
try:
captcha_valid = await sync_to_async(verify_slider_captcha)(slider_captcha_key, int(slider_captcha_x))
if not captcha_valid:
await sync_to_async(record_failure)(operation, identifier)
return create_standardized_error_response(
code=ResponseCode.CAPTCHA_ERROR,
status_code=status.HTTP_400_BAD_REQUEST
)
except SliderCaptchaError:
return create_standardized_error_response(
code=ResponseCode.CAPTCHA_EXPIRED,
status_code=status.HTTP_400_BAD_REQUEST
)
try:
user = await sync_to_async(authenticate)(username=account, password=password)
if user is not None:
if user.is_active:
refresh = RefreshToken.for_user(user)
await sync_to_async(reset_failures)(operation, identifier)
refresh = await sync_to_async(RefreshToken.for_user)(user)
user_data = await UserSerializer(user).adata
response_data = {
@@ -701,6 +803,7 @@ class UserLoginAPIView(APIView):
status_code=status.HTTP_200_OK
)
else:
await sync_to_async(record_failure)(operation, identifier)
await sync_to_async(_create_login_record)(request, user, 'failed')
return create_standardized_error_response(
@@ -709,6 +812,7 @@ class UserLoginAPIView(APIView):
status_code=status.HTTP_403_FORBIDDEN
)
else:
await sync_to_async(record_failure)(operation, identifier)
login_user = await FUser.objects.filter(username=account).afirst() or await FUser.objects.filter(email=account).afirst()
if login_user:
await sync_to_async(_create_login_record)(request, login_user, 'failed')
@@ -731,9 +835,11 @@ class LoginView(APIView):
permission_classes = [AllowAny]
def _get_identifier(self, request):
x_forwarded_for = request.META.get('HTTP_X_FORWARDED_FOR')
if x_forwarded_for:
return x_forwarded_for.split(',')[0].strip()
"""安全修复:X-Forwarded-For 首段可被客户端任意伪造;改用 nginx 覆写的
X-Real-IP(网关以 $remote_addr 设置,客户端伪造值会被覆盖),无代理时回退 REMOTE_ADDR。"""
real_ip = request.META.get('HTTP_X_REAL_IP') or request.META.get('REMOTE_ADDR')
if real_ip:
return str(real_ip).strip()
return request.META.get('REMOTE_ADDR')
@swagger_auto_schema(
@@ -797,7 +903,7 @@ class LoginView(APIView):
if user is not None:
if user.is_active:
await sync_to_async(reset_failures)(operation, identifier)
refresh = RefreshToken.for_user(user)
refresh = await sync_to_async(RefreshToken.for_user)(user)
user_data = await UserSerializer(user).adata
response_data = {