diff --git a/app/auth.py b/app/auth.py index 0d620c9..ef1b247 100644 --- a/app/auth.py +++ b/app/auth.py @@ -176,6 +176,27 @@ def generate_refresh_token() -> str: return "".join(secrets.choice(characters) for _ in range(length)) +async def invalidate_user_tokens(db: AsyncSession, user_id: int) -> int: + """使指定用户的所有令牌失效 + + 返回删除的令牌数量 + """ + # 使用 select 先获取所有令牌 + stmt = select(OAuthToken).where(OAuthToken.user_id == user_id) + result = await db.exec(stmt) + tokens = result.all() + + # 逐个删除令牌 + count = 0 + for token in tokens: + await db.delete(token) + count += 1 + + # 提交更改 + await db.commit() + return count + + def verify_token(token: str) -> dict | None: """验证访问令牌""" try: diff --git a/app/router/auth.py b/app/router/auth.py index a7717e3..db8cfe1 100644 --- a/app/router/auth.py +++ b/app/router/auth.py @@ -35,6 +35,7 @@ from app.service.email_verification_service import ( EmailVerificationService, LoginSessionService ) +from app.service.password_reset_service import password_reset_service from fastapi import APIRouter, Depends, Form, Request from fastapi.responses import JSONResponse @@ -561,3 +562,95 @@ async def oauth_token( refresh_token=refresh_token_str, scope=" ".join(scopes), ) + + +@router.post( + "/password-reset/request", + name="请求密码重置", + description="通过邮箱请求密码重置验证码" +) +async def request_password_reset( + request: Request, + email: str = Form(..., description="邮箱地址"), + redis: Redis = Depends(get_redis), +): + """ + 请求密码重置 + """ + from app.dependencies.geoip import get_client_ip + + # 获取客户端信息 + ip_address = get_client_ip(request) + user_agent = request.headers.get("User-Agent", "") + + # 请求密码重置 + success, message = await password_reset_service.request_password_reset( + email=email.lower().strip(), + ip_address=ip_address, + user_agent=user_agent, + redis=redis + ) + + if success: + return JSONResponse( + status_code=200, + content={ + "success": True, + "message": message + } + ) + else: + return JSONResponse( + status_code=400, + content={ + "success": False, + "error": message + } + ) + + +@router.post( + "/password-reset/reset", + name="重置密码", + description="使用验证码重置密码" +) +async def reset_password( + request: Request, + email: str = Form(..., description="邮箱地址"), + reset_code: str = Form(..., description="重置验证码"), + new_password: str = Form(..., description="新密码"), + redis: Redis = Depends(get_redis), +): + """ + 重置密码 + """ + from app.dependencies.geoip import get_client_ip + + # 获取客户端信息 + ip_address = get_client_ip(request) + + # 重置密码 + success, message = await password_reset_service.reset_password( + email=email.lower().strip(), + reset_code=reset_code.strip(), + new_password=new_password, + ip_address=ip_address, + redis=redis + ) + + if success: + return JSONResponse( + status_code=200, + content={ + "success": True, + "message": message + } + ) + else: + return JSONResponse( + status_code=400, + content={ + "success": False, + "error": message + } + ) diff --git a/app/service/email_service.py b/app/service/email_service.py index 3685b15..0562c4d 100644 --- a/app/service/email_service.py +++ b/app/service/email_service.py @@ -98,7 +98,7 @@ class EmailService:
-

osu! 邮箱验证

+

osu! 邮箱验证

Email Verification

diff --git a/app/service/enhanced_interval_stats.py b/app/service/enhanced_interval_stats.py index 671ffb9..6ea6f17 100644 --- a/app/service/enhanced_interval_stats.py +++ b/app/service/enhanced_interval_stats.py @@ -480,14 +480,19 @@ class EnhancedIntervalStatsManager: keys = await redis_async.keys(pattern) for key in keys: try: - # 从key中提取时间 - time_part = key.decode().split(":")[-1] # YYYYMMDD_HHMM格式 + # 从key中提取时间,处理字节或字符串类型 + if isinstance(key, bytes): + key_str = key.decode() + else: + key_str = key + time_part = key_str.split(":")[-1] # YYYYMMDD_HHMM格式 key_time = datetime.strptime(time_part, "%Y%m%d_%H%M") if key_time < cutoff_time: await redis_async.delete(key) # 也删除对应的用户集合 - await redis_async.delete(f"{INTERVAL_ONLINE_USERS_KEY}:{key}") + # 使用key_str确保正确拼接用户集合键 + await redis_async.delete(f"{INTERVAL_ONLINE_USERS_KEY}:{key_str}") await redis_async.delete(f"{INTERVAL_PLAYING_USERS_KEY}:{key}") except (ValueError, IndexError): diff --git a/app/service/password_reset_service.py b/app/service/password_reset_service.py new file mode 100644 index 0000000..afe2865 --- /dev/null +++ b/app/service/password_reset_service.py @@ -0,0 +1,429 @@ +""" +密码重置服务 +""" + +from __future__ import annotations + +import secrets +import string +from datetime import datetime, UTC, timedelta +from typing import Optional, Tuple +import json + +from app.config import settings +from app.database import User +from app.dependencies.database import with_db +from app.service.email_service import EmailService +from app.log import logger +from app.auth import get_password_hash, invalidate_user_tokens + +from sqlmodel import select +from sqlmodel.ext.asyncio.session import AsyncSession +from redis.asyncio import Redis + + +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": datetime.now(UTC).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"[Password Reset] 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"[Password Reset] Email sending failed, cleaned up Redis data for {email}") + return False, "邮件发送失败,请稍后重试" + + except Exception as e: + # Redis操作失败,清理可能的部分数据 + try: + await redis.delete(reset_code_key) + await redis.delete(rate_limit_key) + except: + pass + logger.error(f"[Password Reset] Redis operation failed: {e}") + return False, "服务暂时不可用,请稍后重试" + + async def send_password_reset_email(self, email: str, code: str, username: str) -> bool: + """发送密码重置邮件""" + try: + from email.mime.text import MIMEText + from email.mime.multipart import MIMEMultipart + import smtplib + + msg = MIMEMultipart() + msg['From'] = f"{self.email_service.from_name} <{self.email_service.from_email}>" + msg['To'] = email + msg['Subject'] = "密码重置 - Password Reset" + + # 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.

+
+ + +
+ + + """ + + msg.attach(MIMEText(html_content, 'html', 'utf-8')) + + # 发送邮件 + if not getattr(settings, 'enable_email_sending', True): + # 邮件发送功能禁用时只记录日志,不实际发送 + logger.info(f"[Password Reset] Mock sending reset code to {email}: {code}") + return True + + with smtplib.SMTP(self.email_service.smtp_server, self.email_service.smtp_port) as server: + if self.email_service.smtp_username and self.email_service.smtp_password: + server.starttls() + server.login(self.email_service.smtp_username, self.email_service.smtp_password) + + server.send_message(msg) + + logger.info(f"[Password Reset] Successfully sent reset code to {email}") + return True + + except Exception as e: + logger.error(f"[Password Reset] Failed to send 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"] = datetime.now(UTC).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"[Password Reset] User {user_id} ({email}) successfully reset password from IP {ip_address}, invalidated {tokens_deleted} tokens") + return True, "密码重置成功,所有设备已被登出" + + except Exception as e: + # 不要在异常处理中访问user.id,可能触发数据库操作 + user_id = reset_data.get("user_id", "未知") + logger.error(f"[Password Reset] 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 = (datetime.now(UTC) - 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"[Password Reset] Restored Redis state after database rollback for {email}") + else: + # 如果已经过期,直接删除 + await redis.delete(reset_code_key) + logger.info(f"[Password Reset] Removed expired reset code after database rollback for {email}") + + except Exception as redis_error: + logger.error(f"[Password Reset] 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"[Password Reset] Failed to get attempts count: {e}") + return 0 + + +# 全局密码重置服务实例 +password_reset_service = PasswordResetService() diff --git a/app/service/ranking_cache_service.py b/app/service/ranking_cache_service.py index 8006f8d..d8f51a6 100644 --- a/app/service/ranking_cache_service.py +++ b/app/service/ranking_cache_service.py @@ -225,7 +225,7 @@ class RankingCacheService: ) -> None: """刷新排行榜缓存""" if self._refreshing: - logger.info( + logger.debug( f"Ranking cache refresh already in progress for {ruleset}:{type}" ) return @@ -308,7 +308,7 @@ class RankingCacheService: except Exception as e: logger.error(f"Error caching page {page} for {ruleset}:{type}: {e}") - logger.info(f"Completed ranking cache refresh for {ruleset}:{type}") + logger.debug(f"Completed ranking cache refresh for {ruleset}:{type}") except Exception as e: logger.error(f"Ranking cache refresh failed for {ruleset}:{type}: {e}") @@ -323,7 +323,7 @@ class RankingCacheService: ) -> None: """刷新地区排行榜缓存""" if self._refreshing: - logger.info( + logger.debug( f"Country ranking cache refresh already in progress for {ruleset}" ) return diff --git a/migrations/versions/d103d442dc24_add_password_reset_table.py b/migrations/versions/d103d442dc24_add_password_reset_table.py new file mode 100644 index 0000000..e1c7fcf --- /dev/null +++ b/migrations/versions/d103d442dc24_add_password_reset_table.py @@ -0,0 +1,172 @@ +"""add_password_reset_table + +Revision ID: d103d442dc24 +Revises: 0f96348cdfd2 +Create Date: 2025-08-22 08:27:58.468119 + +""" +from __future__ import annotations + +from collections.abc import Sequence + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import mysql +import sqlmodel + +# revision identifiers, used by Alembic. +revision: str = "d103d442dc24" +down_revision: str | Sequence[str] | None = "0f96348cdfd2" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.create_table("password_resets", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("user_id", sa.BigInteger(), nullable=False), + sa.Column("email", sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column("reset_code", sqlmodel.sql.sqltypes.AutoString(length=8), nullable=False), + sa.Column("created_at", sa.DateTime(), nullable=False), + sa.Column("expires_at", sa.DateTime(), nullable=False), + sa.Column("is_used", sa.Boolean(), nullable=False), + sa.Column("used_at", sa.DateTime(), nullable=True), + sa.Column("ip_address", sqlmodel.sql.sqltypes.AutoString(), nullable=True), + sa.Column("user_agent", sqlmodel.sql.sqltypes.AutoString(), nullable=True), + sa.ForeignKeyConstraint(["user_id"], ["lazer_users.id"], ), + sa.PrimaryKeyConstraint("id") + ) + op.create_index(op.f("ix_password_resets_email"), "password_resets", ["email"], unique=False) + op.create_index(op.f("ix_password_resets_user_id"), "password_resets", ["user_id"], unique=False) + op.drop_index(op.f("ix_two_factor_auth_id"), table_name="two_factor_auth") + op.drop_index(op.f("ix_two_factor_auth_user_id"), table_name="two_factor_auth") + op.drop_table("two_factor_auth") + op.drop_index(op.f("ix_user_ip_history_id"), table_name="user_ip_history") + op.drop_index(op.f("ix_user_ip_history_ip_address"), table_name="user_ip_history") + op.drop_index(op.f("ix_user_ip_history_user_id"), table_name="user_ip_history") + op.drop_table("user_ip_history") + op.drop_index(op.f("ix_session_verification_user_id"), table_name="session_verification") + op.drop_table("session_verification") + op.alter_column("beatmapsets", "nsfw", + existing_type=mysql.TINYINT(display_width=1), + nullable=True) + op.alter_column("beatmapsets", "spotlight", + existing_type=mysql.TINYINT(display_width=1), + nullable=True) + op.alter_column("beatmapsets", "video", + existing_type=mysql.TINYINT(display_width=1), + nullable=True) + op.alter_column("beatmapsets", "can_be_hyped", + existing_type=mysql.TINYINT(display_width=1), + nullable=True) + op.alter_column("beatmapsets", "discussion_locked", + existing_type=mysql.TINYINT(display_width=1), + nullable=True) + op.alter_column("beatmapsets", "storyboard", + existing_type=mysql.TINYINT(display_width=1), + nullable=True) + op.alter_column("beatmapsets", "download_disabled", + existing_type=mysql.TINYINT(display_width=1), + nullable=True) + op.drop_index(op.f("uq_user_achievement"), table_name="lazer_user_achievements") + op.alter_column("scores", "has_replay", + existing_type=mysql.TINYINT(display_width=1), + nullable=True) + op.alter_column("scores", "passed", + existing_type=mysql.TINYINT(display_width=1), + nullable=True) + op.alter_column("scores", "preserve", + existing_type=mysql.TINYINT(display_width=1), + nullable=True) + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.alter_column("scores", "preserve", + existing_type=mysql.TINYINT(display_width=1), + nullable=False) + op.alter_column("scores", "passed", + existing_type=mysql.TINYINT(display_width=1), + nullable=False) + op.alter_column("scores", "has_replay", + existing_type=mysql.TINYINT(display_width=1), + nullable=False) + op.create_index(op.f("uq_user_achievement"), "lazer_user_achievements", ["user_id", "achievement_id"], unique=True) + op.alter_column("beatmapsets", "download_disabled", + existing_type=mysql.TINYINT(display_width=1), + nullable=False) + op.alter_column("beatmapsets", "storyboard", + existing_type=mysql.TINYINT(display_width=1), + nullable=False) + op.alter_column("beatmapsets", "discussion_locked", + existing_type=mysql.TINYINT(display_width=1), + nullable=False) + op.alter_column("beatmapsets", "can_be_hyped", + existing_type=mysql.TINYINT(display_width=1), + nullable=False) + op.alter_column("beatmapsets", "video", + existing_type=mysql.TINYINT(display_width=1), + nullable=False) + op.alter_column("beatmapsets", "spotlight", + existing_type=mysql.TINYINT(display_width=1), + nullable=False) + op.alter_column("beatmapsets", "nsfw", + existing_type=mysql.TINYINT(display_width=1), + nullable=False) + op.create_table("session_verification", + sa.Column("id", mysql.BIGINT(), autoincrement=True, nullable=False), + sa.Column("user_id", mysql.BIGINT(), autoincrement=False, nullable=False), + sa.Column("session_id", mysql.VARCHAR(length=255), nullable=False), + sa.Column("ip_address", mysql.VARCHAR(length=45), nullable=False), + sa.Column("is_verified", mysql.TINYINT(display_width=1), autoincrement=False, nullable=False), + sa.Column("verified_at", mysql.DATETIME(), nullable=True), + sa.Column("expires_at", mysql.DATETIME(), nullable=False), + sa.ForeignKeyConstraint(["user_id"], ["lazer_users.id"], name=op.f("session_verification_ibfk_1")), + sa.PrimaryKeyConstraint("id"), + mysql_collate="utf8mb4_0900_ai_ci", + mysql_default_charset="utf8mb4", + mysql_engine="InnoDB" + ) + op.create_index(op.f("ix_session_verification_user_id"), "session_verification", ["user_id"], unique=False) + op.create_table("user_ip_history", + sa.Column("id", mysql.BIGINT(), autoincrement=True, nullable=False), + sa.Column("user_id", mysql.BIGINT(), autoincrement=False, nullable=False), + sa.Column("ip_address", mysql.VARCHAR(length=45), nullable=False), + sa.Column("first_seen", mysql.DATETIME(), nullable=False), + sa.Column("last_seen", mysql.DATETIME(), nullable=False), + sa.Column("usage_count", mysql.INTEGER(), autoincrement=False, nullable=False), + sa.Column("is_trusted", mysql.TINYINT(display_width=1), autoincrement=False, nullable=False), + sa.ForeignKeyConstraint(["user_id"], ["lazer_users.id"], name=op.f("user_ip_history_ibfk_1")), + sa.PrimaryKeyConstraint("id"), + mysql_collate="utf8mb4_0900_ai_ci", + mysql_default_charset="utf8mb4", + mysql_engine="InnoDB" + ) + op.create_index(op.f("ix_user_ip_history_user_id"), "user_ip_history", ["user_id"], unique=False) + op.create_index(op.f("ix_user_ip_history_ip_address"), "user_ip_history", ["ip_address"], unique=False) + op.create_index(op.f("ix_user_ip_history_id"), "user_ip_history", ["id"], unique=False) + op.create_table("two_factor_auth", + sa.Column("id", mysql.BIGINT(), autoincrement=True, nullable=False), + sa.Column("user_id", mysql.BIGINT(), autoincrement=False, nullable=False), + sa.Column("verification_code", mysql.VARCHAR(length=8), nullable=False), + sa.Column("expires_at", mysql.DATETIME(), nullable=False), + sa.Column("is_used", mysql.TINYINT(display_width=1), autoincrement=False, nullable=False), + sa.Column("ip_address", mysql.VARCHAR(length=45), nullable=False), + sa.Column("trigger_reason", mysql.VARCHAR(length=50), nullable=False), + sa.Column("verified_at", mysql.DATETIME(), nullable=True), + sa.ForeignKeyConstraint(["user_id"], ["lazer_users.id"], name=op.f("two_factor_auth_ibfk_1")), + sa.PrimaryKeyConstraint("id"), + mysql_collate="utf8mb4_0900_ai_ci", + mysql_default_charset="utf8mb4", + mysql_engine="InnoDB" + ) + op.create_index(op.f("ix_two_factor_auth_user_id"), "two_factor_auth", ["user_id"], unique=False) + op.create_index(op.f("ix_two_factor_auth_id"), "two_factor_auth", ["id"], unique=False) + op.drop_index(op.f("ix_password_resets_user_id"), table_name="password_resets") + op.drop_index(op.f("ix_password_resets_email"), table_name="password_resets") + op.drop_table("password_resets") + # ### end Alembic commands ###