""" 密码重置服务 """ from datetime import datetime import json import secrets import string from app.auth import get_password_hash, invalidate_user_tokens from app.database import User from app.dependencies.database import with_db from app.log import logger from app.service.email_queue import email_queue # 导入邮件队列 from app.service.email_service import EmailService from app.utils import utcnow from redis.asyncio import Redis from sqlmodel import select class PasswordResetService: """密码重置服务 - 使用Redis管理验证码""" # Redis键前缀 RESET_CODE_PREFIX = "password_reset:code:" # 存储验证码 RESET_RATE_LIMIT_PREFIX = "password_reset:rate_limit:" # 限制请求频率 def __init__(self): self.email_service = EmailService() def generate_reset_code(self) -> str: """生成8位重置验证码""" return "".join(secrets.choice(string.digits) for _ in range(8)) def _get_reset_code_key(self, email: str) -> str: """获取验证码Redis键""" return f"{self.RESET_CODE_PREFIX}{email.lower()}" def _get_rate_limit_key(self, email: str) -> str: """获取频率限制Redis键""" return f"{self.RESET_RATE_LIMIT_PREFIX}{email.lower()}" async def request_password_reset( self, email: str, ip_address: str, user_agent: str, redis: Redis ) -> tuple[bool, str]: """ 请求密码重置 Args: email: 邮箱地址 ip_address: 请求IP user_agent: 用户代理 redis: Redis连接 Returns: Tuple[success, message] """ email = email.lower().strip() async with with_db() as session: # 查找用户 user_query = select(User).where(User.email == email) user_result = await session.exec(user_query) user = user_result.first() if not user: # 为了安全考虑,不告诉用户邮箱不存在,但仍然要检查频率限制 rate_limit_key = self._get_rate_limit_key(email) if await redis.get(rate_limit_key): return False, "请求过于频繁,请稍后再试" # 设置一个假的频率限制,防止恶意用户探测邮箱 await redis.setex(rate_limit_key, 60, "1") return True, "如果该邮箱地址存在,您将收到密码重置邮件" # 检查频率限制 rate_limit_key = self._get_rate_limit_key(email) if await redis.get(rate_limit_key): return False, "请求过于频繁,请稍后再试" # 生成重置验证码 reset_code = self.generate_reset_code() # 存储验证码信息到Redis reset_code_key = self._get_reset_code_key(email) reset_data = { "user_id": user.id, "email": email, "reset_code": reset_code, "created_at": utcnow().isoformat(), "ip_address": ip_address, "user_agent": user_agent, "used": False, } try: # 先设置频率限制 await redis.setex(rate_limit_key, 60, "1") # 存储验证码,10分钟过期 await redis.setex(reset_code_key, 600, json.dumps(reset_data)) # 发送重置邮件 email_sent = await self.send_password_reset_email(email=email, code=reset_code, username=user.username) if email_sent: logger.info(f"Sent reset code to user {user.id} ({email})") return True, "密码重置邮件已发送,请查收邮箱" else: # 邮件发送失败,清理Redis中的数据 await redis.delete(reset_code_key) await redis.delete(rate_limit_key) logger.warning(f"Email sending failed, cleaned up Redis data for {email}") return False, "邮件发送失败,请稍后重试" except Exception: # Redis操作失败,清理可能的部分数据 try: await redis.delete(reset_code_key) await redis.delete(rate_limit_key) except Exception: logger.warning("Failed to clean up Redis data after error") logger.exception("Redis operation failed") return False, "服务暂时不可用,请稍后重试" async def send_password_reset_email(self, email: str, code: str, username: str) -> bool: """发送密码重置邮件(使用邮件队列)""" try: # HTML 邮件内容 html_content = f"""

osu! 密码重置

Password Reset Request

你好 {username}!

我们收到了您的密码重置请求。如果这是您本人操作,请使用以下验证码重置密码:

{code}

这个验证码将在 10 分钟后过期

⚠️ 安全提醒:
  • 请不要与任何人分享这个验证码
  • 如果您没有请求密码重置,请立即忽略这封邮件
  • 验证码只能使用一次
  • 建议设置一个强密码以保护您的账户安全

如果您有任何问题,请联系我们的支持团队。


Hello {username}!

We received a request to reset your password. If this was you, please use the following verification code to reset your password:

This verification code will expire in 10 minutes.

Security Notice: Do not share this verification code with anyone. If you did not request a password reset, please ignore this email.

""" # noqa: E501 # 纯文本内容(作为备用) plain_content = f""" 你好 {username}! 我们收到了您的密码重置请求。如果这是您本人操作,请使用以下验证码重置密码: {code} 这个验证码将在10分钟后过期。 安全提醒: - 请不要与任何人分享这个验证码 - 如果您没有请求密码重置,请立即忽略这封邮件 - 验证码只能使用一次 - 建议设置一个强密码以保护您的账户安全 如果您有任何问题,请联系我们的支持团队。 © 2025 g0v0! Private Server. 此邮件由系统自动发送,请勿回复。 """ # 添加邮件到队列 subject = "密码重置 - Password Reset" metadata = {"type": "password_reset", "email": email, "code": code} await email_queue.enqueue_email( to_email=email, subject=subject, content=plain_content, html_content=html_content, metadata=metadata, ) logger.info(f"Enqueued reset code email to {email}") return True except Exception as e: logger.error(f"Failed to enqueue email: {e}") return False async def reset_password( self, email: str, reset_code: str, new_password: str, ip_address: str, redis: Redis, ) -> tuple[bool, str]: """ 重置密码 Args: email: 邮箱地址 reset_code: 重置验证码 new_password: 新密码 ip_address: 请求IP redis: Redis连接 Returns: Tuple[success, message] """ email = email.lower().strip() reset_code = reset_code.strip() async with with_db() as session: # 从Redis获取验证码数据 reset_code_key = self._get_reset_code_key(email) reset_data_str = await redis.get(reset_code_key) if not reset_data_str: return False, "验证码无效或已过期" try: reset_data = json.loads(reset_data_str) except json.JSONDecodeError: return False, "验证码数据格式错误" # 验证验证码 if reset_data.get("reset_code") != reset_code: return False, "验证码错误" # 检查是否已使用 if reset_data.get("used", False): return False, "验证码已使用" # 验证邮箱匹配 if reset_data.get("email") != email: return False, "邮箱地址不匹配" # 查找用户 user_query = select(User).where(User.email == email) user_result = await session.exec(user_query) user = user_result.first() if not user: return False, "用户不存在" if user.id is None: return False, "用户ID无效" # 验证用户ID匹配 if reset_data.get("user_id") != user.id: return False, "用户信息不匹配" # 密码强度检查 if len(new_password) < 6: return False, "密码长度至少为6位" try: # 先标记验证码为已使用(在数据库操作之前) reset_data["used"] = True reset_data["used_at"] = utcnow().isoformat() # 保存用户ID用于日志记录 user_id = user.id # 更新用户密码 password_hash = get_password_hash(new_password) user.pw_bcrypt = password_hash # 使用正确的字段名称 pw_bcrypt 而不是 password_hash # 提交数据库更改 await session.commit() # 使该用户的所有现有令牌失效(使其他客户端登录失效) tokens_deleted = await invalidate_user_tokens(session, user_id) # 数据库操作成功后,更新Redis状态 await redis.setex(reset_code_key, 300, json.dumps(reset_data)) # 保留5分钟用于日志记录 logger.info( f"User {user_id} ({email}) successfully reset password from IP {ip_address}," f" invalidated {tokens_deleted} tokens" ) return True, "密码重置成功,所有设备已被登出" except Exception as e: # 不要在异常处理中访问user.id,可能触发数据库操作 user_id = reset_data.get("user_id", "未知") logger.error(f"Failed to reset password for user {user_id}: {e}") await session.rollback() # 数据库回滚时,需要恢复Redis中的验证码状态 try: # 恢复验证码为未使用状态 original_reset_data = { "user_id": reset_data.get("user_id"), "email": reset_data.get("email"), "reset_code": reset_data.get("reset_code"), "created_at": reset_data.get("created_at"), "ip_address": reset_data.get("ip_address"), "user_agent": reset_data.get("user_agent"), "used": False, # 恢复为未使用状态 } # 计算剩余的TTL时间 created_at = datetime.fromisoformat(reset_data.get("created_at", "")) elapsed = (utcnow() - created_at).total_seconds() remaining_ttl = max(0, 600 - int(elapsed)) # 600秒总过期时间 if remaining_ttl > 0: await redis.setex( reset_code_key, remaining_ttl, json.dumps(original_reset_data), ) logger.info(f"Restored Redis state after database rollback for {email}") else: # 如果已经过期,直接删除 await redis.delete(reset_code_key) logger.info(f"Removed expired reset code after database rollback for {email}") except Exception as redis_error: logger.error(f"Failed to restore Redis state after rollback: {redis_error}") return False, "密码重置失败,请稍后重试" async def get_reset_attempts_count(self, email: str, redis: Redis) -> int: """ 获取邮箱的重置尝试次数(通过检查频率限制键) Args: email: 邮箱地址 redis: Redis连接 Returns: 尝试次数 """ try: rate_limit_key = self._get_rate_limit_key(email) ttl = await redis.ttl(rate_limit_key) return 1 if ttl > 0 else 0 except Exception as e: logger.error(f"Failed to get attempts count: {e}") return 0 # 全局密码重置服务实例 password_reset_service = PasswordResetService()