""" 认证服务 """ from datetime import datetime, timedelta from typing import Optional from fastapi import status from sqlalchemy.ext.asyncio import AsyncSession from app.core.security import security_manager from app.core.exceptions import ( InvalidCredentialsException, UserLockedException, UserDisabledException, CaptchaException ) from app.crud.user import user_crud from app.models.user import User from app.schemas.user import LoginResponse, UserInfo from app.core.config import settings import uuid class AuthService: """认证服务类""" def __init__(self): self.max_login_failures = 5 self.lock_duration_minutes = 30 async def login( self, db: AsyncSession, username: str, password: str, captcha: str, captcha_key: str ) -> LoginResponse: """ 用户登录 Args: db: 数据库会话 username: 用户名 password: 密码 captcha: 验证码 captcha_key: 验证码UUID Returns: LoginResponse: 登录响应 Raises: InvalidCredentialsException: 认证失败 UserLockedException: 用户被锁定 UserDisabledException: 用户被禁用 """ # 验证验证码 import logging logger = logging.getLogger(__name__) logger.info(f"开始验证验证码 - captcha_key: {captcha_key}, captcha: {captcha}") captcha_valid = await self._verify_captcha(captcha_key, captcha) logger.info(f"验证码验证结果: {captcha_valid}") if not captcha_valid: logger.warning(f"验证码验证失败 - captcha_key: {captcha_key}") raise CaptchaException() # 获取用户 user = await user_crud.get_by_username(db, username) if not user: raise InvalidCredentialsException("用户名或密码错误") # 检查用户状态 if user.status == "disabled": raise UserDisabledException() if user.status == "locked": # 检查是否已过锁定时间 if user.locked_until and user.locked_until > datetime.utcnow(): raise UserLockedException(f"账户已被锁定,请在 {user.locked_until.strftime('%Y-%m-%d %H:%M:%S')} 后重试") else: # 解锁用户 user.status = "active" user.locked_until = None user.login_fail_count = 0 await db.commit() # 验证密码 if not security_manager.verify_password(password, user.password_hash): # 增加失败次数 user.login_fail_count += 1 # 检查是否需要锁定 if user.login_fail_count >= self.max_login_failures: user.status = "locked" user.locked_until = datetime.utcnow() + timedelta(minutes=self.lock_duration_minutes) await db.commit() if user.status == "locked": raise UserLockedException(f"密码错误次数过多,账户已被锁定 {self.lock_duration_minutes} 分钟") raise InvalidCredentialsException("用户名或密码错误") # 登录成功,重置失败次数 await user_crud.update_last_login(db, user) # 生成Token access_token = security_manager.create_access_token( data={"sub": str(user.id), "username": user.username} ) refresh_token = security_manager.create_refresh_token( data={"sub": str(user.id), "username": user.username} ) # 获取用户角色和权限 user_info = await self._build_user_info(db, user) return LoginResponse( access_token=access_token, refresh_token=refresh_token, token_type="Bearer", expires_in=settings.ACCESS_TOKEN_EXPIRE_MINUTES * 60, user=user_info ) async def refresh_token(self, db: AsyncSession, refresh_token: str) -> dict: """ 刷新访问令牌 Args: db: 数据库会话 refresh_token: 刷新令牌 Returns: dict: 包含新的访问令牌 """ payload = security_manager.verify_token(refresh_token, token_type="refresh") user_id = int(payload.get("sub")) user = await user_crud.get(db, user_id) if not user or user.status != "active": raise InvalidCredentialsException("用户不存在或已被禁用") # 生成新的访问令牌 access_token = security_manager.create_access_token( data={"sub": str(user.id), "username": user.username} ) return { "access_token": access_token, "expires_in": settings.ACCESS_TOKEN_EXPIRE_MINUTES * 60 } async def change_password( self, db: AsyncSession, user: User, old_password: str, new_password: str ) -> bool: """ 修改密码 Args: db: 数据库会话 user: 当前用户 old_password: 旧密码 new_password: 新密码 Returns: bool: 是否修改成功 """ # 验证旧密码 if not security_manager.verify_password(old_password, user.password_hash): raise InvalidCredentialsException("旧密码错误") # 更新密码 return await user_crud.update_password(db, user, new_password) async def reset_password( self, db: AsyncSession, user_id: int, new_password: str ) -> bool: """ 重置用户密码(管理员功能) Args: db: 数据库会话 user_id: 用户ID new_password: 新密码 Returns: bool: 是否重置成功 """ user = await user_crud.get(db, user_id) if not user: return False return await user_crud.update_password(db, user, new_password) async def _generate_captcha(self) -> dict: """ 生成验证码 Returns: 包含captcha_key和captcha_base64的字典 """ from app.utils.redis_client import redis_client import random import string import base64 from io import BytesIO from PIL import Image, ImageDraw, ImageFont # 生成4位随机验证码,使用更清晰的字符组合(排除易混淆的字符) captcha_text = ''.join(random.choices('23456789', k=4)) # 排除0、1 # 生成验证码图片 width, height = 200, 80 # 增大图片尺寸 # 使用浅色背景而不是纯白 background_color = (245, 245, 250) # 浅蓝灰色 image = Image.new('RGB', (width, height), color=background_color) draw = ImageDraw.Draw(image) # 尝试使用更大的字体 try: # 优先使用系统大字体 font = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", 48) except: try: font = ImageFont.truetype("arial.ttf", 48) # 增大到48 except: font = ImageFont.load_default() # 如果使用默认字体,尝试放大 font = font.font_variant(size=48) # 绘制验证码 draw.text((10, 5), captcha_text, fill='black', font=font) # 减少干扰线数量(从5条减少到3条) for _ in range(3): x1 = random.randint(0, width) y1 = random.randint(0, height) x2 = random.randint(0, width) y2 = random.randint(0, height) draw.line([(x1, y1), (x2, y2)], fill='gray', width=1) # 添加噪点(可选) for _ in range(50): x = random.randint(0, width - 1) y = random.randint(0, height - 1) draw.point((x, y), fill='lightgray') # 转换为base64 buffer = BytesIO() image.save(buffer, format='PNG') image_base64 = base64.b64encode(buffer.getvalue()).decode('utf-8') # 生成captcha_key captcha_key = ''.join(random.choices(string.ascii_letters + string.digits, k=32)) # 存储到Redis,5分钟过期 await redis_client.setex( f"captcha:{captcha_key}", 300, captcha_text ) return { "captcha_key": captcha_key, "captcha_base64": f"data:image/png;base64,{image_base64}" } async def _verify_captcha(self, captcha_key: str, captcha: str) -> bool: """ 验证验证码 Args: captcha_key: 验证码密钥 captcha: 用户输入的验证码 Returns: 验证是否成功 """ import logging logger = logging.getLogger(__name__) from app.utils.redis_client import redis_client try: # 从Redis获取存储的验证码 stored_captcha = await redis_client.get(f"captcha:{captcha_key}") logger.info(f"Redis中存储的验证码: {stored_captcha}, 用户输入: {captcha}") if not stored_captcha: logger.warning(f"验证码已过期或不存在 - captcha_key: {captcha_key}") return False # 验证码不区分大小写 is_valid = stored_captcha.lower() == captcha.lower() logger.info(f"验证码匹配结果: {is_valid}") return is_valid except Exception as e: logger.error(f"验证码验证异常: {str(e)}", exc_info=True) return False async def _build_user_info(self, db: AsyncSession, user: User) -> UserInfo: """ 构建用户信息 Args: db: 数据库会话 user: 用户对象 Returns: UserInfo: 用户信息 """ # 获取用户角色代码列表 role_codes = [role.role_code for role in user.roles] # 获取用户权限代码列表 permissions = [] for role in user.roles: for perm in role.permissions: permissions.append(perm.permission_code) # 如果是超级管理员,给予所有权限 if user.is_admin: permissions = ["*:*:*"] return UserInfo( id=user.id, username=user.username, real_name=user.real_name, email=user.email, avatar_url=user.avatar_url, is_admin=user.is_admin, status=user.status ) # 创建服务实例 auth_service = AuthService()