seaislee1209 be656900c0
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 2m13s
feat: v0.9.7 登录风控第二期 — IP归属地解析 + 异常检测(R1-R5) + 飞书告警 + 自动封禁
- IP138 在线 API + ip2region 离线库双通道归属地解析,60 秒熔断降级
- 5 条异常检测规则:地区不对/不可能旅行/频繁登录/团队遍地开花/海外IP太杂
- 飞书 interactive 卡片告警(红色严重/橙色警告),含辅助指标
- R2 自动封禁用户、R4 自动封禁团队,封禁即踢下线
- 系统设置页全局配置 + 团队详情页独立阈值覆盖
- 安全日志页面 + 管理员修改密码入口

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-19 00:02:56 +08:00

240 lines
8.7 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

from rest_framework import status
from rest_framework.decorators import api_view, permission_classes, throttle_classes
from rest_framework.permissions import AllowAny, IsAuthenticated
from rest_framework.response import Response
from rest_framework.throttling import ScopedRateThrottle
from django.contrib.auth import authenticate, get_user_model
from django.utils import timezone
from django.db.models import Sum
from .serializers import UserSerializer
from .models import ActiveSession, LoginRecord, get_client_ip, parse_device_type
from .tokens import SessionRefreshToken
from django.contrib.auth.hashers import check_password
User = get_user_model()
class LoginRateThrottle(ScopedRateThrottle):
scope = 'login'
@api_view(['POST'])
@permission_classes([AllowAny])
def register_view(request):
"""POST /api/v1/auth/register — disabled, all accounts created by admins."""
return Response(
{'error': 'registration_disabled', 'message': '公开注册已关闭,请联系管理员'},
status=status.HTTP_403_FORBIDDEN,
)
def _enforce_session_limit(user, device_type):
"""Enforce concurrent session limits: remove oldest sessions if over limit."""
from apps.generation.models import QuotaConfig
config = QuotaConfig.objects.filter(pk=1).first()
if device_type == 'desktop':
max_sessions = config.max_desktop_sessions if config else 1
elif device_type == 'mobile':
max_sessions = config.max_mobile_sessions if config else 0
else:
max_sessions = 1
if max_sessions <= 0:
# 0 means no sessions allowed for this device type — but still allow login
# (treat as unlimited for unknown device types)
if device_type == 'unknown':
return
# For mobile with limit 0, still allow (no mobile enforcement yet)
return
existing = ActiveSession.objects.filter(
user=user, device_type=device_type
).order_by('created_at')
# If at or over limit, delete oldest sessions to make room for the new one
over_count = existing.count() - max_sessions + 1
if over_count > 0:
ids_to_remove = list(existing.values_list('id', flat=True)[:over_count])
ActiveSession.objects.filter(id__in=ids_to_remove).delete()
@api_view(['POST'])
@permission_classes([AllowAny])
@throttle_classes([LoginRateThrottle])
def login_view(request):
"""POST /api/v1/auth/login"""
username = request.data.get('username', '').strip()
password = request.data.get('password', '')
# Try authenticate with username first, then email
user = authenticate(username=username, password=password)
if user is None:
# Try email login
try:
user_by_email = User.objects.get(email=username)
user = authenticate(username=user_by_email.username, password=password)
except User.DoesNotExist:
pass
if user is None:
return Response(
{'error': 'invalid_credentials', 'message': '用户名或密码错误'},
status=status.HTTP_401_UNAUTHORIZED
)
# Check if user or team is disabled
if not user.is_active:
code = 'user_disabled'
return Response(
{'code': code, 'message': '您的账号已被禁用,请联系团队管理员'},
status=status.HTTP_401_UNAUTHORIZED
)
if user.team and not user.team.is_active:
code = 'team_disabled'
return Response(
{'code': code, 'message': '您所在的团队已被禁用,请联系平台管理员'},
status=status.HTTP_403_FORBIDDEN
)
# Record login IP and User-Agent
ip = get_client_ip(request)
user_agent = request.META.get('HTTP_USER_AGENT', '')
login_record = LoginRecord.objects.create(
user=user, team=user.team, ip_address=ip, user_agent=user_agent,
)
# IP 归属地解析 + 异常检测(不阻塞登录)
try:
from utils.geo_client import resolve_ip_location
country, province, city, source = resolve_ip_location(ip)
login_record.geo_country = country
login_record.geo_province = province
login_record.geo_city = city
login_record.geo_source = source
login_record.save(update_fields=['geo_country', 'geo_province', 'geo_city', 'geo_source'])
from utils.anomaly_detector import check_login_anomaly, process_anomalies
anomalies = check_login_anomaly(login_record)
if anomalies:
process_anomalies(login_record, anomalies)
# 封禁后重新检查anomaly_detector 可能刚封禁了用户/团队)
user.refresh_from_db()
if not user.is_active:
return Response(
{'code': 'user_disabled', 'message': '您的账号已被禁用,请联系团队管理员'},
status=status.HTTP_401_UNAUTHORIZED
)
if user.team:
user.team.refresh_from_db()
if not user.team.is_active:
return Response(
{'code': 'team_disabled', 'message': '您所在的团队已被禁用,请联系平台管理员'},
status=status.HTTP_403_FORBIDDEN
)
except Exception:
import logging
logging.getLogger(__name__).exception('Anomaly detection failed for login %s', login_record.pk)
# Concurrent session management
device_type = parse_device_type(user_agent)
_enforce_session_limit(user, device_type)
session = ActiveSession.objects.create(user=user, device_type=device_type, user_agent=user_agent)
refresh = SessionRefreshToken.for_user_session(user, session.session_id)
return Response({
'user': UserSerializer(user).data,
'tokens': {
'access': str(refresh.access_token),
'refresh': str(refresh),
}
})
@api_view(['GET'])
@permission_classes([IsAuthenticated])
def me_view(request):
"""GET /api/v1/auth/me — returns role, team info, and quota."""
user = request.user
today = timezone.now().date()
first_of_month = today.replace(day=1)
daily_seconds_used = user.generation_records.filter(
created_at__date=today
).aggregate(total=Sum('seconds_consumed'))['total'] or 0
monthly_seconds_used = user.generation_records.filter(
created_at__date__gte=first_of_month
).aggregate(total=Sum('seconds_consumed'))['total'] or 0
data = UserSerializer(user).data
data['quota'] = {
'daily_seconds_limit': user.daily_seconds_limit,
'daily_seconds_used': daily_seconds_used,
'monthly_seconds_limit': user.monthly_seconds_limit,
'monthly_seconds_used': monthly_seconds_used,
}
# Team info
team = user.team
if team:
# Team monthly consumption
from apps.generation.models import GenerationRecord
team_monthly_used = GenerationRecord.objects.filter(
user__team=team,
created_at__date__gte=first_of_month,
).aggregate(total=Sum('seconds_consumed'))['total'] or 0
data['team'] = {
'id': team.id,
'name': team.name,
'total_seconds_pool': team.total_seconds_pool,
'total_seconds_used': team.total_seconds_used,
'remaining_seconds': team.remaining_seconds,
'monthly_seconds_limit': team.monthly_seconds_limit,
'monthly_seconds_used': team_monthly_used,
'is_active': team.is_active,
}
data['team_disabled'] = not team.is_active
else:
data['team'] = None
data['team_disabled'] = False
return Response(data)
@api_view(['POST'])
@permission_classes([IsAuthenticated])
def change_password_view(request):
"""POST /api/v1/auth/change-password — user changes own password."""
old_password = request.data.get('old_password', '')
new_password = request.data.get('new_password', '')
if not old_password or not new_password:
return Response(
{'error': 'missing_fields', 'message': '请填写旧密码和新密码'},
status=status.HTTP_400_BAD_REQUEST,
)
if len(new_password) < 8:
return Response(
{'error': 'password_too_short', 'message': '新密码至少8位'},
status=status.HTTP_400_BAD_REQUEST,
)
if not check_password(old_password, request.user.password):
return Response(
{'error': 'wrong_password', 'message': '旧密码错误'},
status=status.HTTP_400_BAD_REQUEST,
)
request.user.set_password(new_password)
request.user.must_change_password = False
request.user.save(update_fields=['password', 'must_change_password'])
return Response({
'message': '密码修改成功',
'user': UserSerializer(request.user).data,
})