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:
+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 = {
|
||||
|
||||
Reference in New Issue
Block a user