Files
DjangoAdmin_FastAPI_EleVue/modules/auth/service.py
T
ClaudeandClaude 64a5504396 feat(phase4): 全部业务模块迁移到 modules/(结构迁移)
- 21 个模块目录(auth/system/dictionary/configuration/cms/member/region/link/notice/job/log/upload/index)
- 每个模块 router.py(合并模块导出多个 router)/schemas.py/service.py/repository.py/models.py
- routers/v1.py 与 routers/__init__.py 改为直接引用 modules,前缀与权限串原样保留
- core/middleware.py 懒加载指向 modules.system.menu / modules.log;core/redis_client.py 指向 modules.job.scheduler
- apps/views|services|forms 全部改为 re-export 垫片
- 迁移后 grep 守门通过:modules/core 无 apps/extensions 残留引用
- 实测登录、27+ 模块接口、写操作 commit、操作日志落库均正常

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-07 10:13:30 +08:00

106 lines
3.9 KiB
Python

import asyncio
from fastapi.logger import logger
from sqlalchemy import and_
from modules.auth.schemas import LoginForm
from modules.system.user.models import User
from modules.log import service as login_log
from core.database import get_db_session
from core import R
from core import password
from core.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 core.redis_client 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})