import asyncio from fastapi.logger import logger from sqlalchemy import and_ from apps.forms.login import LoginForm from apps.models.user import User from apps.services import login_log from extensions import get_db_session from utils import R from utils import password from utils.jwt import create_token from utils.captcha import verify_captcha from utils.ip import get_client_ip from config import LOGIN_FAIL_MAX, LOGIN_LOCK_SECONDS # Redis Key 前缀 _LOGIN_FAIL_PREFIX = 'login:fail:' _LOGIN_LOCK_PREFIX = 'login:lock:' def _fail_key(ip: str, username: str) -> str: """登录失败计数Key:按IP+用户名维度""" return f'{_LOGIN_FAIL_PREFIX}{ip}:{username}' def _lock_key(ip: str, username: str) -> str: """登录锁定Key""" return f'{_LOGIN_LOCK_PREFIX}{ip}:{username}' async def _is_locked(redis, ip: str, username: str) -> tuple[bool, int]: """检查是否处于锁定状态,返回 (是否锁定, 剩余秒数)""" key = _lock_key(ip, username) ttl = await redis.ttl(key) if ttl and ttl > 0: return True, ttl return False, 0 async def _record_fail(redis, ip: str, username: str): """记录一次登录失败,达到上限则锁定""" key = _fail_key(ip, username) count = await redis.incr(key) # 首次失败时设置过期时间(与锁定时间一致,避免残留) if count == 1: await redis.expire(key, LOGIN_LOCK_SECONDS) # 达到上限,设置锁定 if count >= LOGIN_FAIL_MAX: lock_k = _lock_key(ip, username) await redis.set(lock_k, '1', ex=LOGIN_LOCK_SECONDS) logger.warning(f"登录锁定:IP={ip}, 用户={username}, 连续失败{count}次") async def _clear_fail(redis, ip: str, username: str): """登录成功后清除失败记录""" fail_k = _fail_key(ip, username) lock_k = _lock_key(ip, username) await redis.delete(fail_k, lock_k) # 系统登录 async def login(request, data: LoginForm): # 获取客户端IP ip = get_client_ip(request) # 检查是否被锁定 from utils.redis_ctx import get_redis redis = get_redis() locked, remaining = await _is_locked(redis, ip, data.username) if locked: return R.failed(f"登录失败次数过多,请{remaining}秒后再试") # 验证码校验(验证成功后自动删除,防止重放攻击) if not await verify_captcha(data.idKey, data.captcha): return R.failed("验证码不正确") # 根据用户名查询用户信息(状态为1-正常,2-禁用),同步DB查询放入线程池 def _query_user(): db = get_db_session() return db.query(User).filter(and_(User.username == data.username, User.is_delete == 0, User.status == 1)).first() user = await asyncio.to_thread(_query_user) if not user: # 记录失败但不透露具体原因(用户不存在、已禁用、密码错误统一提示) await _record_fail(redis, ip, data.username) await asyncio.to_thread(login_log.record_login_log, request, data.username, 1, 0, 1, error="用户名或密码不正确") return R.failed("用户名或密码不正确") # 比对密码是否相同 if not password.verify_password(data.password, user.salt or '', user.password or ''): await _record_fail(redis, ip, data.username) await asyncio.to_thread(login_log.record_login_log, request, data.username, 1, 0, 1, error="用户名或密码不正确") return R.failed("用户名或密码不正确") # 登录成功,清除失败记录 await _clear_fail(redis, ip, data.username) # 生成TOKEN access_token = create_token({"userId": user.id, "username": user.username, "realname": user.realname or ''}) # 记录登录成功日志 await asyncio.to_thread(login_log.record_login_log, request, data.username, 1, 0, 0, result="登录成功") # 返回结果 return R.ok(msg="登录成功", data={"access_token": access_token})