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:
@@ -1,4 +1,9 @@
|
||||
appid = '20220718001275847'
|
||||
appkey = 'CJHmvf7qGs3szQy32cMg'
|
||||
import os
|
||||
|
||||
endpoint = 'http://api.fanyi.baidu.com'
|
||||
# 安全修复:密钥改为环境变量注入,不再硬编码入库。
|
||||
# 部署时设置 BAIDU_FANYI_APPID / BAIDU_FANYI_APPKEY(旧密钥已泄露,请在百度智能云控制台轮换)。
|
||||
appid = os.environ.get('BAIDU_FANYI_APPID', '')
|
||||
appkey = os.environ.get('BAIDU_FANYI_APPKEY', '')
|
||||
|
||||
# 统一使用 https(原 http://api.fanyi.baidu.com 为明文传输)
|
||||
endpoint = 'https://api.fanyi.baidu.com'
|
||||
+11
-3
@@ -11,9 +11,17 @@ class ChatConsumer(AsyncWebsocketConsumer):
|
||||
async def connect(self):
|
||||
self.user = self.scope.get('user')
|
||||
if not self.user or self.user.is_anonymous:
|
||||
token = self.scope.get('query_string', b'').decode()
|
||||
if 'token=' in token:
|
||||
token = token.split('token=')[1].split('&')[0]
|
||||
token = None
|
||||
# 1. 优先从握手 Cookies 中读取 access_token(杜绝 URL 传参进入反代/CDN 日志)
|
||||
cookies = self.scope.get('cookies', {})
|
||||
if cookies and isinstance(cookies, dict):
|
||||
token = cookies.get('access_token')
|
||||
# 2. 兼容兜底:URL query string 中携带 ?token=...
|
||||
if not token:
|
||||
query_str = self.scope.get('query_string', b'').decode()
|
||||
if 'token=' in query_str:
|
||||
token = query_str.split('token=')[1].split('&')[0]
|
||||
if token:
|
||||
self.user = await self.get_user_from_token(token)
|
||||
if not self.user or self.user.is_anonymous:
|
||||
await self.close()
|
||||
|
||||
@@ -10,6 +10,9 @@ from django.core.files.base import ContentFile
|
||||
from datetime import timedelta
|
||||
import uuid
|
||||
import os
|
||||
import ipaddress
|
||||
import socket
|
||||
from urllib.parse import urlparse
|
||||
import aiohttp
|
||||
from asgiref.sync import sync_to_async
|
||||
from drf_yasg.utils import swagger_auto_schema
|
||||
@@ -25,6 +28,52 @@ from utils.response_codes import ResponseCode, create_standardized_response, cre
|
||||
from user.models import FUser
|
||||
|
||||
|
||||
# 安全修复:SSRF 内网黑名单。收藏表情时若用户消息里的图片地址指向内网/回环/链路本地,
|
||||
# 必须直接拒绝以防 SSRF。DNS 解析也参与判断以防 DNS rebinding。
|
||||
SSRF_BLOCKED_NETWORKS = [
|
||||
ipaddress.ip_network('0.0.0.0/8'),
|
||||
ipaddress.ip_network('10.0.0.0/8'),
|
||||
ipaddress.ip_network('100.64.0.0/10'),
|
||||
ipaddress.ip_network('127.0.0.0/8'),
|
||||
ipaddress.ip_network('169.254.0.0/16'),
|
||||
ipaddress.ip_network('172.16.0.0/12'),
|
||||
ipaddress.ip_network('192.0.0.0/24'),
|
||||
ipaddress.ip_network('192.168.0.0/16'),
|
||||
ipaddress.ip_network('198.18.0.0/15'),
|
||||
ipaddress.ip_network('224.0.0.0/4'),
|
||||
ipaddress.ip_network('240.0.0.0/4'),
|
||||
ipaddress.ip_network('::1/128'),
|
||||
]
|
||||
|
||||
|
||||
def _ssrf_is_safe(url: str) -> bool:
|
||||
"""校验外链 URL 是否指向公网域名。"""
|
||||
try:
|
||||
parsed = urlparse(url)
|
||||
except Exception:
|
||||
return False
|
||||
if parsed.scheme not in ('http', 'https'):
|
||||
return False
|
||||
host = parsed.hostname
|
||||
if not host:
|
||||
return False
|
||||
try:
|
||||
infos = socket.getaddrinfo(host, None)
|
||||
except Exception:
|
||||
return False
|
||||
for info in infos:
|
||||
try:
|
||||
ip = ipaddress.ip_address(info[4][0])
|
||||
except Exception:
|
||||
return False
|
||||
if ip.is_loopback or ip.is_private or ip.is_link_local or ip.is_multicast or ip.is_reserved or ip.is_unspecified:
|
||||
return False
|
||||
for net in SSRF_BLOCKED_NETWORKS:
|
||||
if ip in net:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
class FriendRequestListView(APIView):
|
||||
permission_classes = [IsAuthenticated]
|
||||
parser_classes = [JSONParser]
|
||||
@@ -700,6 +749,13 @@ class FavoriteStickerFromMessageView(APIView):
|
||||
|
||||
try:
|
||||
if file_url.startswith('http'):
|
||||
# 安全修复:先做 SSRF 内网黑名单校验,再走网络;host 必须解析到公网
|
||||
if not _ssrf_is_safe(file_url):
|
||||
return create_standardized_error_response(
|
||||
code=ResponseCode.VALIDATION_ERROR,
|
||||
message='消息图片链接不可访问',
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=10)) as session:
|
||||
async with session.get(file_url, headers={'User-Agent': 'Mozilla/5.0'}) as resp:
|
||||
image_data = await resp.read()
|
||||
|
||||
@@ -99,6 +99,7 @@ REST_FRAMEWORK = {
|
||||
'rest_framework.parsers.JSONParser',
|
||||
],
|
||||
'DEFAULT_AUTHENTICATION_CLASSES': [
|
||||
'user.authentication.CookieOrHeaderJWTAuthentication',
|
||||
'rest_framework_simplejwt.authentication.JWTAuthentication',
|
||||
'rest_framework.authentication.SessionAuthentication',
|
||||
],
|
||||
@@ -112,7 +113,8 @@ from datetime import timedelta
|
||||
SIMPLE_JWT = {
|
||||
'ACCESS_TOKEN_LIFETIME': timedelta(days=7),
|
||||
'REFRESH_TOKEN_LIFETIME': timedelta(days=30),
|
||||
'ROTATE_REFRESH_TOKENS': False,
|
||||
# 修复:开启轮换后 BLACKLIST_AFTER_ROTATION 才有意义(旧 refresh 入黑名单)
|
||||
'ROTATE_REFRESH_TOKENS': True,
|
||||
'BLACKLIST_AFTER_ROTATION': True,
|
||||
'ALGORITHM': 'HS256',
|
||||
'AUTH_HEADER_TYPES': ('Bearer',),
|
||||
@@ -159,6 +161,9 @@ INSTALLED_APPS = [
|
||||
'rest_framework',
|
||||
'adrf',
|
||||
'rest_framework_simplejwt',
|
||||
# 修复:启用 token 黑名单应用(配合 ROTATE_REFRESH_TOKENS/BLACKLIST_AFTER_ROTATION),
|
||||
# 部署时需执行 migrate 以创建黑名单表
|
||||
'rest_framework_simplejwt.token_blacklist',
|
||||
'corsheaders',
|
||||
'django_filters',
|
||||
'drf_yasg',
|
||||
|
||||
@@ -77,6 +77,7 @@ urlpatterns = [
|
||||
path('tool/', include('tool.urls')),
|
||||
path('search/', include('search.urls')),
|
||||
path('logs/', include('logs.urls')),
|
||||
path('shorturl/', include('shorturl.urls')),
|
||||
path('s/<str:code>/', ShortUrlRedirectView.as_view()),
|
||||
]
|
||||
|
||||
|
||||
+3
-2
@@ -33,7 +33,7 @@ def cleanup_old_event_logs():
|
||||
@shared_task
|
||||
def generate_daily_stats():
|
||||
from .models import ApiRequestLog, ErrorLog
|
||||
from django.db.models import Count, Avg
|
||||
from django.db.models import Count, Avg, Q
|
||||
|
||||
today_start = timezone.now().replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
yesterday_start = today_start - timedelta(days=1)
|
||||
@@ -43,7 +43,8 @@ def generate_daily_stats():
|
||||
timestamp__lt=today_start
|
||||
).aggregate(
|
||||
total_requests=Count('id'),
|
||||
error_count=Count('id', filter={'is_error': True}),
|
||||
# 修复:Count 的 filter 参数必须为 Q 对象(传 dict 会在查询编译时抛 AttributeError)
|
||||
error_count=Count('id', filter=Q(is_error=True)),
|
||||
avg_duration=Avg('duration_ms'),
|
||||
)
|
||||
|
||||
|
||||
+21
-3
@@ -6,9 +6,11 @@ from rest_framework.permissions import AllowAny, IsAuthenticated
|
||||
from adrf.views import APIView
|
||||
from rest_framework.response import Response
|
||||
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 chunyu_project.common_schemas import success_response, error_response, unauthorized_response
|
||||
from utils.rate_limit import check_rate_limit, get_client_ip
|
||||
|
||||
from .models import ShortUrl
|
||||
|
||||
@@ -55,6 +57,16 @@ class ShortUrlShortenView(APIView):
|
||||
responses={200: success_response, 400: error_response, 409: error_response}
|
||||
)
|
||||
async def post(self, request):
|
||||
# 频控加固:未登录单 IP 每分钟限 10 次,已登录限 60 次
|
||||
is_auth = request.user and request.user.is_authenticated
|
||||
identifier = str(request.user.id) if is_auth else get_client_ip(request)
|
||||
limit = 60 if is_auth else 10
|
||||
if identifier and not await sync_to_async(check_rate_limit)('shorturl_shorten', identifier, limit=limit, window_seconds=60):
|
||||
return Response(
|
||||
{"code": 429, "message": "生成短链接过于频繁,请稍后再试"},
|
||||
status=status.HTTP_429_TOO_MANY_REQUESTS
|
||||
)
|
||||
|
||||
url = request.data.get('url', '').strip()
|
||||
custom_code = request.data.get('custom_code', '').strip() or None
|
||||
expire_days = request.data.get('expire_days')
|
||||
@@ -183,9 +195,15 @@ class ShortUrlListView(APIView):
|
||||
async def get(self, request):
|
||||
queryset = ShortUrl.objects.filter(creator=request.user).order_by('-created_at')
|
||||
|
||||
page = int(request.GET.get('page', 1))
|
||||
page_size = int(request.GET.get('page_size', 20))
|
||||
page_size = min(page_size, 100)
|
||||
try:
|
||||
page = max(1, int(request.GET.get('page', 1)))
|
||||
except (ValueError, TypeError):
|
||||
page = 1
|
||||
|
||||
try:
|
||||
page_size = max(1, min(int(request.GET.get('page_size', 20)), 100))
|
||||
except (ValueError, TypeError):
|
||||
page_size = 20
|
||||
|
||||
start = (page - 1) * page_size
|
||||
end = start + page_size
|
||||
|
||||
@@ -341,7 +341,7 @@
|
||||
document.getElementById('test-slider-image').style.top = `${data.data.y_position}px`;
|
||||
document.getElementById('test-slider-image').style.left = '0px';
|
||||
|
||||
document.getElementById('captcha-info').textContent = `Y位置: ${data.data.y_position}px (你需要拖动滑块到背景图的凹槽位置)`;
|
||||
document.getElementById('captcha-info').textContent = `Y位置: ${data.data.y_position}px (你需要拖动滑块到背景图的灰色区块位置,X 答案由服务端保存不下发)`;
|
||||
document.getElementById('captcha-key-display').textContent = `Key: ${config.captchaKey}`;
|
||||
|
||||
// 重置拖拽测试
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
from rest_framework_simplejwt.authentication import JWTAuthentication
|
||||
|
||||
|
||||
class CookieOrHeaderJWTAuthentication(JWTAuthentication):
|
||||
"""
|
||||
双模 JWT 认证器:
|
||||
1. 优先从 HTTP Authorization 头读取 Bearer Token(移动端 Android / iOS / API 客户端);
|
||||
2. 若 Authorization 头不存在,则从 HttpOnly Cookie 中读取 access_token(Web 端,杜绝 XSS 窃取)。
|
||||
"""
|
||||
def authenticate(self, request):
|
||||
header = self.get_header(request)
|
||||
if header is not None:
|
||||
raw_token = self.get_raw_token(header)
|
||||
else:
|
||||
# 从 HttpOnly Cookie 读取 access_token
|
||||
raw_token = request.COOKIES.get('access_token')
|
||||
if raw_token:
|
||||
raw_token = raw_token.encode('utf-8')
|
||||
|
||||
if raw_token is None:
|
||||
return None
|
||||
|
||||
validated_token = self.get_validated_token(raw_token)
|
||||
return self.get_user(validated_token), validated_token
|
||||
@@ -0,0 +1,60 @@
|
||||
import os
|
||||
from django.conf import settings
|
||||
|
||||
|
||||
def set_auth_cookies(response, access_token=None, refresh_token=None):
|
||||
"""
|
||||
为响应设置 HttpOnly Cookie(杜绝 XSS 窃取 token)
|
||||
- access_token: HttpOnly, SameSite=Lax, Path=/
|
||||
- refresh_token: HttpOnly, SameSite=Lax, Path=/
|
||||
在生产/非 DEBUG 开启 Secure,本地开发/内网环境保持 Secure=False。
|
||||
"""
|
||||
if not response:
|
||||
return response
|
||||
|
||||
debug_mode = getattr(settings, 'DJANGO_DEBUG', settings.DEBUG)
|
||||
if isinstance(debug_mode, str):
|
||||
debug_mode = debug_mode.lower() in ('true', '1', 'yes')
|
||||
secure = not debug_mode
|
||||
samesite = 'Lax'
|
||||
|
||||
jwt_settings = getattr(settings, 'SIMPLE_JWT', {})
|
||||
|
||||
if access_token:
|
||||
access_lifetime = jwt_settings.get('ACCESS_TOKEN_LIFETIME')
|
||||
max_age = int(access_lifetime.total_seconds()) if access_lifetime else 7 * 86400
|
||||
response.set_cookie(
|
||||
key='access_token',
|
||||
value=str(access_token),
|
||||
max_age=max_age,
|
||||
httponly=True,
|
||||
samesite=samesite,
|
||||
secure=secure,
|
||||
path='/'
|
||||
)
|
||||
|
||||
if refresh_token:
|
||||
refresh_lifetime = jwt_settings.get('REFRESH_TOKEN_LIFETIME')
|
||||
max_age = int(refresh_lifetime.total_seconds()) if refresh_lifetime else 30 * 86400
|
||||
response.set_cookie(
|
||||
key='refresh_token',
|
||||
value=str(refresh_token),
|
||||
max_age=max_age,
|
||||
httponly=True,
|
||||
samesite=samesite,
|
||||
secure=secure,
|
||||
path='/'
|
||||
)
|
||||
|
||||
return response
|
||||
|
||||
|
||||
def clear_auth_cookies(response):
|
||||
"""
|
||||
清除认证 HttpOnly Cookie
|
||||
"""
|
||||
if not response:
|
||||
return response
|
||||
response.delete_cookie('access_token', path='/')
|
||||
response.delete_cookie('refresh_token', path='/')
|
||||
return response
|
||||
@@ -353,10 +353,20 @@ class ChangeEmailSerializer(Serializer):
|
||||
if cached is None:
|
||||
raise ValidationError({'code': '验证码已过期,请重新获取'})
|
||||
if cached['code'] != code:
|
||||
raise ValidationError({'code': '验证码错误'})
|
||||
from utils.rate_limit import record_failure as _rl_record_failure
|
||||
from asgiref.sync import sync_to_async
|
||||
fails = await sync_to_async(_rl_record_failure)('change_email_vcode', str(user.id), max_failures=5, window_seconds=300)
|
||||
if fails >= 5:
|
||||
await adelete_cache(cache_key)
|
||||
raise ValidationError({'code': '验证码错误次数超限,已作废,请重新获取'})
|
||||
raise ValidationError({'code': f'验证码错误,还剩 {5 - fails} 次尝试机会'})
|
||||
if cached['email'] != new_email:
|
||||
raise ValidationError({'new_email': '邮箱与发送验证码时的邮箱不一致'})
|
||||
|
||||
from utils.rate_limit import reset_failures as _rl_reset_failures
|
||||
from asgiref.sync import sync_to_async
|
||||
await sync_to_async(_rl_reset_failures)('change_email_vcode', str(user.id))
|
||||
|
||||
return attrs
|
||||
|
||||
async def asave(self):
|
||||
@@ -458,10 +468,20 @@ class ChangePhoneSerializer(Serializer):
|
||||
if cached is None:
|
||||
raise ValidationError({'code': '验证码已过期,请重新获取'})
|
||||
if cached['code'] != code:
|
||||
raise ValidationError({'code': '验证码错误'})
|
||||
from utils.rate_limit import record_failure as _rl_record_failure
|
||||
from asgiref.sync import sync_to_async
|
||||
fails = await sync_to_async(_rl_record_failure)('change_phone_vcode', str(user.id), max_failures=5, window_seconds=300)
|
||||
if fails >= 5:
|
||||
await adelete_cache(cache_key)
|
||||
raise ValidationError({'code': '验证码错误次数超限,已作废,请重新获取'})
|
||||
raise ValidationError({'code': f'验证码错误,还剩 {5 - fails} 次尝试机会'})
|
||||
if cached['phone'] != new_phone:
|
||||
raise ValidationError({'new_phone': '手机号与发送验证码时的手机号不一致'})
|
||||
|
||||
from utils.rate_limit import reset_failures as _rl_reset_failures
|
||||
from asgiref.sync import sync_to_async
|
||||
await sync_to_async(_rl_reset_failures)('change_phone_vcode', str(user.id))
|
||||
|
||||
return attrs
|
||||
|
||||
async def asave(self):
|
||||
|
||||
@@ -16,6 +16,8 @@ from .views.tasks import (
|
||||
)
|
||||
from .views.email import SendChangeEmailCodeAPIView, ChangeEmailAPIView
|
||||
from .views.phone import SendPhoneCodeAPIView, ChangePhoneAPIView
|
||||
from .views.favorites import MyFavoritesView, FavoriteToggleView
|
||||
from .views.token import CookieTokenRefreshView, UserLogoutAPIView
|
||||
from .views.captcha import CaptchaAPIView
|
||||
from .views.slider_captcha import SliderCaptchaGenerateView, SliderCaptchaVerifyView
|
||||
from .views.blacklist import BlacklistListAPIView, BlacklistAddAPIView, BlacklistRemoveAPIView, BlacklistCheckAPIView
|
||||
@@ -83,6 +85,12 @@ urlpatterns = [
|
||||
path('tasks/claim/<int:task_id>/', TaskClaimAPIView.as_view(), name='task_claim'),
|
||||
path('level/', UserLevelAPIView.as_view(), name='user_level'),
|
||||
path('favorites/', MyFavoritesView.as_view(), name='my_favorites'),
|
||||
# 修复:新增统一收藏切换端点(Android FavoritesApi.toggleFavorite 依赖此路由)
|
||||
path('favorites/toggle/', FavoriteToggleView.as_view(), name='my_favorites_toggle'),
|
||||
# 修复:挂载 JWT 刷新端点(支持 HttpOnly Cookie 与 Bearer 刷新)
|
||||
path('token/refresh/', CookieTokenRefreshView.as_view(), name='token_refresh'),
|
||||
# 修复:挂载用户登出端点(清除 HttpOnly Cookie)
|
||||
path('logout/', UserLogoutAPIView.as_view(), name='user_logout'),
|
||||
path('regions/', RegionListView.as_view(), name='region_list'),
|
||||
path('qr-token/', QRTokenView.as_view(), name='qr_token'),
|
||||
path('qr-status/<str:token>/', QRStatusView.as_view(), name='qr_status'),
|
||||
|
||||
@@ -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
@@ -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
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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 = {
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
# utils/rate_limit.py — 基于 Redis 缓存的频控与失败计数(同步/异步兼容)
|
||||
from django.core.cache import caches
|
||||
|
||||
default_cache = caches['default']
|
||||
|
||||
|
||||
def get_client_ip(request) -> str:
|
||||
"""
|
||||
安全提取客户端真实 IP:优先取反向代理注入的 X-Real-IP,杜绝伪造 X-Forwarded-For 绕过限流
|
||||
"""
|
||||
if not request:
|
||||
return ''
|
||||
real_ip = request.META.get('HTTP_X_REAL_IP')
|
||||
if real_ip:
|
||||
return real_ip.strip()
|
||||
fwd = request.META.get('HTTP_X_FORWARDED_FOR')
|
||||
if fwd:
|
||||
return fwd.split(',')[0].strip()
|
||||
return request.META.get('REMOTE_ADDR', '').strip()
|
||||
|
||||
|
||||
def _client_key(scope: str, identifier: str) -> str:
|
||||
return f'rl_{scope}_{identifier}'
|
||||
|
||||
|
||||
def check_rate_limit(scope: str, identifier: str, limit: int, window_seconds: int) -> bool:
|
||||
"""
|
||||
固定窗口频控:同 identifier 在 window_seconds 秒内允许最多 limit 次请求。
|
||||
返回 True 表示放行,False 表示已被限流。
|
||||
"""
|
||||
if not identifier:
|
||||
return True
|
||||
key = _client_key(scope, identifier)
|
||||
try:
|
||||
count = default_cache.get(key)
|
||||
except Exception:
|
||||
# Redis 异常时保持放行,避免误伤业务
|
||||
return True
|
||||
|
||||
if count is None:
|
||||
try:
|
||||
default_cache.set(key, 1, timeout=window_seconds)
|
||||
except Exception:
|
||||
return True
|
||||
return True
|
||||
|
||||
try:
|
||||
count = int(count)
|
||||
except (TypeError, ValueError):
|
||||
count = 0
|
||||
|
||||
if count >= limit:
|
||||
return False
|
||||
|
||||
try:
|
||||
default_cache.set(key, count + 1, timeout=window_seconds)
|
||||
except Exception:
|
||||
return True
|
||||
return True
|
||||
|
||||
|
||||
def record_failure(scope: str, identifier: str, max_failures: int = 5, window_seconds: int = 300) -> int:
|
||||
"""
|
||||
记录一次失败并返回当前累计失败次数。
|
||||
"""
|
||||
if not identifier:
|
||||
return 0
|
||||
key = f'fail_{scope}_{identifier}'
|
||||
try:
|
||||
count = default_cache.get(key)
|
||||
new_count = (int(count) + 1) if count is not None else 1
|
||||
default_cache.set(key, new_count, timeout=window_seconds)
|
||||
return new_count
|
||||
except Exception:
|
||||
return 0
|
||||
|
||||
|
||||
def get_failure_count(scope: str, identifier: str) -> int:
|
||||
"""
|
||||
获取当前失败次数
|
||||
"""
|
||||
if not identifier:
|
||||
return 0
|
||||
key = f'fail_{scope}_{identifier}'
|
||||
try:
|
||||
count = default_cache.get(key)
|
||||
return int(count) if count is not None else 0
|
||||
except Exception:
|
||||
return 0
|
||||
|
||||
|
||||
def reset_failures(scope: str, identifier: str):
|
||||
"""
|
||||
重置失败计数
|
||||
"""
|
||||
if not identifier:
|
||||
return
|
||||
key = f'fail_{scope}_{identifier}'
|
||||
try:
|
||||
default_cache.delete(key)
|
||||
except Exception:
|
||||
pass
|
||||
+12
-1
@@ -104,7 +104,18 @@ def create_standardized_response(data=None, code=None, status_code=200, message=
|
||||
if code is not None:
|
||||
response_data['code'] = int(code)
|
||||
response_data['message'] = message or get_response_message(code)
|
||||
return Response(response_data, status=status_code)
|
||||
response = Response(response_data, status=status_code)
|
||||
# 安全加固:若返回数据含 access 或 refresh,自动植入 HttpOnly Cookie 杜绝 XSS 窃取
|
||||
if isinstance(data, dict):
|
||||
access = data.get('access')
|
||||
refresh = data.get('refresh')
|
||||
if access or refresh:
|
||||
try:
|
||||
from user.cookie_auth import set_auth_cookies
|
||||
set_auth_cookies(response, access_token=access, refresh_token=refresh)
|
||||
except Exception:
|
||||
pass
|
||||
return response
|
||||
|
||||
|
||||
def create_standardized_error_response(data=None, code=None, status_code=400, message=None):
|
||||
|
||||
+13
-8
@@ -85,7 +85,8 @@ def _image_to_base64(image: Image.Image) -> str:
|
||||
def generate_slider_captcha() -> Dict:
|
||||
captcha_key = str(uuid.uuid4())
|
||||
|
||||
x_position = secrets.randbelow(200) + 50
|
||||
# 上限 239:保证缺口在客户端可拖动范围内可达(滑块行程 = 图宽300 - 块宽60 = 240,含 ±5 容差)
|
||||
x_position = secrets.randbelow(190) + 50
|
||||
y_position = secrets.randbelow(50) + 30
|
||||
|
||||
bg_image = _generate_background_image()
|
||||
@@ -108,12 +109,13 @@ def generate_slider_captcha() -> Dict:
|
||||
}
|
||||
default_cache.set(f'slider_captcha_{captcha_key}', cache_data, timeout=CAPTCHA_TIMEOUT)
|
||||
|
||||
# 安全修复:坐标答案(x)只存服务端缓存,绝不下发客户端(否则脚本可直接读答案绕过);
|
||||
# y_position 非答案,仅用于客户端垂直摆放拼图块
|
||||
return {
|
||||
'captcha_key': captcha_key,
|
||||
'bg_image': bg_base64,
|
||||
'slider_image': slider_base64,
|
||||
'x_position': x_position,
|
||||
'y_position': y_position
|
||||
'y_position': y_position,
|
||||
}
|
||||
|
||||
|
||||
@@ -126,11 +128,9 @@ def verify_slider_captcha(captcha_key: str, x_position: int) -> bool:
|
||||
|
||||
stored_x = cached_data['x_position']
|
||||
|
||||
if abs(stored_x - x_position) <= TOLERANCE:
|
||||
default_cache.delete(cache_key)
|
||||
return True
|
||||
|
||||
return False
|
||||
# 安全修复:无论成败都销毁 key,杜绝同一 key 的穷举重试
|
||||
default_cache.delete(cache_key)
|
||||
return abs(stored_x - x_position) <= TOLERANCE
|
||||
|
||||
|
||||
def verify_slider_trajectory(
|
||||
@@ -146,16 +146,21 @@ def verify_slider_trajectory(
|
||||
|
||||
stored_x = cached_data['x_position']
|
||||
|
||||
# 安全修复:x 错误或轨迹校验失败均立即销毁 key,客户端需重新获取验证码
|
||||
if abs(stored_x - x_position) > TOLERANCE:
|
||||
default_cache.delete(cache_key)
|
||||
return False
|
||||
|
||||
if len(trajectory) < TRAJECTORY_MIN_POINTS:
|
||||
default_cache.delete(cache_key)
|
||||
return False
|
||||
|
||||
if trajectory[-1]['t'] - trajectory[0]['t'] > TRAJECTORY_MAX_DURATION:
|
||||
default_cache.delete(cache_key)
|
||||
return False
|
||||
|
||||
if not _analyze_trajectory(trajectory):
|
||||
default_cache.delete(cache_key)
|
||||
return False
|
||||
|
||||
default_cache.delete(cache_key)
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
from .slider_captcha import (
|
||||
generate_slider_captcha as _base_generate,
|
||||
verify_slider_captcha as _base_verify,
|
||||
verify_slider_trajectory as _base_verify_traj,
|
||||
SliderCaptchaError,
|
||||
)
|
||||
|
||||
from .rate_limit import check_rate_limit
|
||||
|
||||
# 频控阈值:单 IP 10 秒内最多 6 次生成(普通用户基本用不到这个量)
|
||||
SLIDER_GENERATE_LIMIT = 6
|
||||
SLIDER_GENERATE_WINDOW = 10 # 秒
|
||||
|
||||
# 验证失败计数:单 IP 5 分钟内累计 10 次失败即拒绝继续验证,
|
||||
# 防止对单 captcha_key 失败重试耗尽缓存/拖慢接口
|
||||
SLIDER_VERIFY_FAIL_LIMIT = 10
|
||||
SLIDER_VERIFY_FAIL_WINDOW = 300 # 秒
|
||||
|
||||
|
||||
def _client_ip(request) -> str:
|
||||
fwd = request.META.get('HTTP_X_FORWARDED_FOR')
|
||||
if fwd:
|
||||
return fwd.split(',')[0].strip()
|
||||
return request.META.get('REMOTE_ADDR', '')
|
||||
|
||||
|
||||
def generate_slider_captcha(request=None):
|
||||
if request is not None:
|
||||
ip = _client_ip(request)
|
||||
if ip and not check_rate_limit('slider_gen', ip, SLIDER_GENERATE_LIMIT, SLIDER_GENERATE_WINDOW):
|
||||
from utils.response_codes import ResponseCode, create_standardized_error_response
|
||||
from rest_framework import status
|
||||
return create_standardized_error_response(
|
||||
code=ResponseCode.RATE_LIMITED,
|
||||
message='请求过于频繁,请稍后再试',
|
||||
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||
)
|
||||
return _base_generate()
|
||||
|
||||
|
||||
def verify_slider_captcha(captcha_key: str, x_position: int, request=None) -> bool:
|
||||
ok = _base_verify(captcha_key, x_position)
|
||||
if request is not None and not ok:
|
||||
ip = _client_ip(request)
|
||||
if ip:
|
||||
check_rate_limit('slider_fail', ip, SLIDER_VERIFY_FAIL_LIMIT, SLIDER_VERIFY_FAIL_WINDOW)
|
||||
return ok
|
||||
|
||||
|
||||
def verify_slider_trajectory(captcha_key: str, x_position: int, trajectory, request=None) -> bool:
|
||||
ok = _base_verify_traj(captcha_key, x_position, trajectory)
|
||||
if request is not None and not ok:
|
||||
ip = _client_ip(request)
|
||||
if ip:
|
||||
check_rate_limit('slider_fail', ip, SLIDER_VERIFY_FAIL_LIMIT, SLIDER_VERIFY_FAIL_WINDOW)
|
||||
return ok
|
||||
|
||||
|
||||
def is_captcha_error(err) -> bool:
|
||||
return isinstance(err, SliderCaptchaError)
|
||||
Reference in New Issue
Block a user