From 361832319263b36388fcaff6dc44dadc0ec1ec08 Mon Sep 17 00:00:00 2001 From: root <1291454411@qq.com> Date: Tue, 8 Sep 2026 11:28:00 +0800 Subject: [PATCH] =?UTF-8?q?fix(security):=20P1/P2=20=E5=AE=A1=E8=AE=A1?= =?UTF-8?q?=E4=BF=AE=E5=A4=8D=20+=20JWT=20HttpOnly=20Cookie=20=E5=8F=8C?= =?UTF-8?q?=E6=A8=A1=E8=AE=A4=E8=AF=81=20+=20=E9=99=90=E6=B5=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- api/views/info/baidu_fanyi_appid.py | 11 ++- chat/consumers.py | 14 ++- chat/views.py | 56 ++++++++++++ chunyu_project/settings.py | 7 +- chunyu_project/urls.py | 1 + logs/tasks.py | 5 +- shorturl/views.py | 24 ++++- templates/test_slider_captcha.html | 2 +- user/authentication.py | 24 +++++ user/cookie_auth.py | 60 +++++++++++++ user/serializers/user_serializers.py | 24 ++++- user/urls.py | 8 ++ user/views/email.py | 20 +++++ user/views/favorites.py | 78 ++++++++++++++++- user/views/phone.py | 20 +++++ user/views/qr_login.py | 4 +- user/views/token.py | 68 +++++++++++++++ user/views/user.py | 126 ++++++++++++++++++++++++--- utils/rate_limit.py | 102 ++++++++++++++++++++++ utils/response_codes.py | 13 ++- utils/slider_captcha.py | 21 +++-- utils/slider_captcha_throttle.py | 60 +++++++++++++ 22 files changed, 711 insertions(+), 37 deletions(-) create mode 100644 user/authentication.py create mode 100644 user/cookie_auth.py create mode 100644 user/views/token.py create mode 100644 utils/rate_limit.py create mode 100644 utils/slider_captcha_throttle.py diff --git a/api/views/info/baidu_fanyi_appid.py b/api/views/info/baidu_fanyi_appid.py index f0cb899..1be1f65 100644 --- a/api/views/info/baidu_fanyi_appid.py +++ b/api/views/info/baidu_fanyi_appid.py @@ -1,4 +1,9 @@ -appid = '20220718001275847' -appkey = 'CJHmvf7qGs3szQy32cMg' +import os -endpoint = 'http://api.fanyi.baidu.com' \ No newline at end of file +# 安全修复:密钥改为环境变量注入,不再硬编码入库。 +# 部署时设置 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' \ No newline at end of file diff --git a/chat/consumers.py b/chat/consumers.py index e226892..c4411c5 100644 --- a/chat/consumers.py +++ b/chat/consumers.py @@ -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() diff --git a/chat/views.py b/chat/views.py index c953971..7f0a215 100644 --- a/chat/views.py +++ b/chat/views.py @@ -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() diff --git a/chunyu_project/settings.py b/chunyu_project/settings.py index 515462d..94082df 100644 --- a/chunyu_project/settings.py +++ b/chunyu_project/settings.py @@ -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', diff --git a/chunyu_project/urls.py b/chunyu_project/urls.py index dacef4a..485c9ae 100644 --- a/chunyu_project/urls.py +++ b/chunyu_project/urls.py @@ -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//', ShortUrlRedirectView.as_view()), ] diff --git a/logs/tasks.py b/logs/tasks.py index 72e4b0a..5eff474 100644 --- a/logs/tasks.py +++ b/logs/tasks.py @@ -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'), ) diff --git a/shorturl/views.py b/shorturl/views.py index 006b079..d522d3d 100644 --- a/shorturl/views.py +++ b/shorturl/views.py @@ -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 diff --git a/templates/test_slider_captcha.html b/templates/test_slider_captcha.html index ac5f6ee..1e3a99a 100644 --- a/templates/test_slider_captcha.html +++ b/templates/test_slider_captcha.html @@ -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}`; // 重置拖拽测试 diff --git a/user/authentication.py b/user/authentication.py new file mode 100644 index 0000000..e52a8ed --- /dev/null +++ b/user/authentication.py @@ -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 diff --git a/user/cookie_auth.py b/user/cookie_auth.py new file mode 100644 index 0000000..696e4d5 --- /dev/null +++ b/user/cookie_auth.py @@ -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 diff --git a/user/serializers/user_serializers.py b/user/serializers/user_serializers.py index 104e130..270ef67 100644 --- a/user/serializers/user_serializers.py +++ b/user/serializers/user_serializers.py @@ -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): diff --git a/user/urls.py b/user/urls.py index 0e3f464..0632dc1 100644 --- a/user/urls.py +++ b/user/urls.py @@ -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//', 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//', QRStatusView.as_view(), name='qr_status'), diff --git a/user/views/email.py b/user/views/email.py index a89eeb3..8926c3b 100644 --- a/user/views/email.py +++ b/user/views/email.py @@ -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) diff --git a/user/views/favorites.py b/user/views/favorites.py index ca93344..426a84c 100644 --- a/user/views/favorites.py +++ b/user/views/favorites.py @@ -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 + ) diff --git a/user/views/phone.py b/user/views/phone.py index 5b9844e..8b59a2d 100644 --- a/user/views/phone.py +++ b/user/views/phone.py @@ -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) diff --git a/user/views/qr_login.py b/user/views/qr_login.py index 25efb40..b7a26b8 100644 --- a/user/views/qr_login.py +++ b/user/views/qr_login.py @@ -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", diff --git a/user/views/token.py b/user/views/token.py new file mode 100644 index 0000000..802dba1 --- /dev/null +++ b/user/views/token.py @@ -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 diff --git a/user/views/user.py b/user/views/user.py index 305bdc2..c7920f0 100644 --- a/user/views/user.py +++ b/user/views/user.py @@ -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 = { diff --git a/utils/rate_limit.py b/utils/rate_limit.py new file mode 100644 index 0000000..372e1f7 --- /dev/null +++ b/utils/rate_limit.py @@ -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 diff --git a/utils/response_codes.py b/utils/response_codes.py index 1ea6a75..e67fd77 100644 --- a/utils/response_codes.py +++ b/utils/response_codes.py @@ -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): diff --git a/utils/slider_captcha.py b/utils/slider_captcha.py index 8162386..1705438 100644 --- a/utils/slider_captcha.py +++ b/utils/slider_captcha.py @@ -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) diff --git a/utils/slider_captcha_throttle.py b/utils/slider_captcha_throttle.py new file mode 100644 index 0000000..4703862 --- /dev/null +++ b/utils/slider_captcha_throttle.py @@ -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)