From 0f9238c501b0ce4bf3fbe1e2d835ee0523554cb5 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E5=92=95=E8=B0=B7=E9=85=B1?=
<74496778+GooGuJiang@users.noreply.github.com>
Date: Fri, 22 Aug 2025 08:56:40 +0800
Subject: [PATCH] =?UTF-8?q?=E6=B7=BB=E5=8A=A0=E5=AF=86=E7=A0=81=E9=87=8D?=
=?UTF-8?q?=E7=BD=AE?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
app/auth.py | 21 +
app/router/auth.py | 93 ++++
app/service/email_service.py | 2 +-
app/service/enhanced_interval_stats.py | 11 +-
app/service/password_reset_service.py | 429 ++++++++++++++++++
app/service/ranking_cache_service.py | 6 +-
.../d103d442dc24_add_password_reset_table.py | 172 +++++++
7 files changed, 727 insertions(+), 7 deletions(-)
create mode 100644 app/service/password_reset_service.py
create mode 100644 migrations/versions/d103d442dc24_add_password_reset_table.py
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:
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"""
+
+
+
+
+
+
+
+
+
+
+
+
你好 {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 ###