Files
chunyu_project/chat/consumers.py
T
root 3618323192 fix(security): P1/P2 审计修复 + JWT HttpOnly Cookie 双模认证 + 限流
- P0/P1 审计修复: 滑块验证码不再下发 x_position/成败即销毁 key、
  user-login 补失败计数+滑块门控、限流标识改 X-Real-IP、
  百度翻译 appkey 环境化、ChangeEmail/ChangePhone 补调 avalidate、
  logs/tasks.py Count(filter=Q) 修复、chat 收藏 SSRF 内网黑名单
- P1 #6/7: token_blacklist + ROTATE_REFRESH_TOKENS 开启,
  /user/token/refresh/ 挂载
- #2 JWT HttpOnly Cookie 双模认证: user/cookie_auth.py 种/清 Cookie,
  user/authentication.py CookieOrHeaderJWTAuthentication(Bearer 优先/_COOKIE 兜底),
  user/views/token.py CookieTokenRefreshView + UserLogoutAPIView(/user/logout/),
  create_standardized_response 自动对含 token 的响应种 Cookie,
  异步视图内 RefreshToken.for_user 全部 sync_to_async 包裹(修 SynchronousOnlyOperation 500),
  WS ChatConsumer 优先读 Cookie token
- P2 #11 限流: utils/rate_limit.py 固定窗口频控,
  shorturl 生成 匿名10次/分+登录60次/分, 邮箱验证码 同邮箱60s1次+同IP10次/10min,
  登录/注册验证码 错5次作废+成功即销毁防重放, 换绑邮箱/手机 同步落地,
  urls.py 补挂 shorturl 路由(此前 404)
2026-09-08 11:28:00 +08:00

244 lines
8.8 KiB
Python

import json
from channels.generic.websocket import AsyncWebsocketConsumer
from channels.db import database_sync_to_async
from django.contrib.auth import get_user_model
from django.utils import timezone
User = get_user_model()
class ChatConsumer(AsyncWebsocketConsumer):
async def connect(self):
self.user = self.scope.get('user')
if not self.user or self.user.is_anonymous:
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()
return
self.user_group = f'user_{self.user.id}'
await self.channel_layer.group_add(self.user_group, self.channel_name)
await self.accept()
await self.broadcast_presence(True)
async def disconnect(self, close_code):
if hasattr(self, 'user_group'):
await self.broadcast_presence(False)
await self.channel_layer.group_discard(self.user_group, self.channel_name)
async def receive(self, text_data):
try:
data = json.loads(text_data)
except json.JSONDecodeError:
return
msg_type = data.get('type', '')
if msg_type == 'chat_message':
await self.handle_chat_message(data)
elif msg_type == 'typing':
await self.handle_typing(data)
elif msg_type == 'read_receipt':
await self.handle_read_receipt(data)
async def handle_chat_message(self, data):
conversation_id = data.get('conversation_id')
content = data.get('content', '').strip()
msg_type = data.get('msg_type', 'text')
file_url = data.get('file_url', '')
reply_to_id = data.get('reply_to')
if not content or not conversation_id:
return
# 检查是否被对方拉黑
is_blocked = await self.check_if_blocked(conversation_id)
if is_blocked:
# 发送错误提示给发送者
await self.send(text_data=json.dumps({
'type': 'error',
'code': 'BLOCKED_BY_USER',
'message': '你已被对方拉黑,无法发送消息',
}))
return
message = await self.save_message(conversation_id, content, msg_type, file_url, reply_to_id)
if not message:
return
participants = await self.get_participants(conversation_id)
message_data = await self.serialize_message(message)
for participant_id in participants:
await self.channel_layer.group_send(
f'user_{participant_id}',
{
'type': 'chat.message',
'conversation_id': conversation_id,
'message': message_data,
}
)
async def handle_typing(self, data):
conversation_id = data.get('conversation_id')
if not conversation_id:
return
participants = await self.get_participants(conversation_id)
for participant_id in participants:
if participant_id != self.user.id:
await self.channel_layer.group_send(
f'user_{participant_id}',
{
'type': 'typing.indicator',
'conversation_id': conversation_id,
'user_id': self.user.id,
}
)
async def handle_read_receipt(self, data):
conversation_id = data.get('conversation_id')
if not conversation_id:
return
await self.update_last_read(conversation_id)
participants = await self.get_participants(conversation_id)
for participant_id in participants:
if participant_id != self.user.id:
await self.channel_layer.group_send(
f'user_{participant_id}',
{
'type': 'read.receipt',
'conversation_id': conversation_id,
'user_id': self.user.id,
}
)
async def broadcast_presence(self, online):
friend_ids = await self.get_friend_ids()
for friend_id in friend_ids:
await self.channel_layer.group_send(
f'user_{friend_id}',
{
'type': 'presence.update',
'user_id': self.user.id,
'online': online,
}
)
async def chat_message(self, event):
await self.send(text_data=json.dumps({
'type': 'chat_message',
'conversation_id': event['conversation_id'],
'message': event['message'],
}))
async def typing_indicator(self, event):
await self.send(text_data=json.dumps({
'type': 'typing',
'conversation_id': event['conversation_id'],
'user_id': event['user_id'],
}))
async def read_receipt(self, event):
await self.send(text_data=json.dumps({
'type': 'read_receipt',
'conversation_id': event['conversation_id'],
'user_id': event['user_id'],
}))
async def presence_update(self, event):
await self.send(text_data=json.dumps({
'type': 'presence',
'user_id': event['user_id'],
'online': event['online'],
}))
@database_sync_to_async
def get_user_from_token(self, token):
from rest_framework_simplejwt.tokens import AccessToken
try:
access_token = AccessToken(token)
user_id = access_token['user_id']
return User.objects.get(id=user_id)
except Exception:
return None
@database_sync_to_async
def save_message(self, conversation_id, content, msg_type, file_url, reply_to_id):
from .models import Conversation, ConversationParticipant, ChatMessage
try:
conversation = Conversation.objects.get(pk=conversation_id)
if not ConversationParticipant.objects.filter(conversation=conversation, user=self.user).exists():
return None
reply_to = None
if reply_to_id:
try:
reply_to = ChatMessage.objects.get(pk=reply_to_id, conversation=conversation)
except ChatMessage.DoesNotExist:
pass
return ChatMessage.objects.create(
conversation=conversation,
sender=self.user,
content=content,
msg_type=msg_type,
file_url=file_url,
reply_to=reply_to,
)
except Exception:
return None
@database_sync_to_async
def get_participants(self, conversation_id):
from .models import ConversationParticipant
return list(ConversationParticipant.objects.filter(
conversation_id=conversation_id
).values_list('user_id', flat=True))
@database_sync_to_async
def serialize_message(self, message):
from .serializers import ChatMessageSerializer
return ChatMessageSerializer(message).data
@database_sync_to_async
def get_friend_ids(self):
from .models import Friendship
from django.db.models import Q
friendships = Friendship.objects.filter(
Q(user1=self.user) | Q(user2=self.user)
)
return [f.user2_id if f.user1 == self.user else f.user1_id for f in friendships]
@database_sync_to_async
def update_last_read(self, conversation_id):
from .models import ConversationParticipant
ConversationParticipant.objects.filter(
conversation_id=conversation_id, user=self.user
).update(last_read_at=timezone.now())
@database_sync_to_async
def check_if_blocked(self, conversation_id):
"""检查当前用户是否被会话中的其他用户拉黑"""
from .models import ConversationParticipant
from user.models import Blacklist
participants = ConversationParticipant.objects.filter(
conversation_id=conversation_id
).exclude(user=self.user).values_list('user_id', flat=True)
return Blacklist.objects.filter(
user_id__in=participants,
blocked_user=self.user
).exists()