v2.4.1: 修复 2FA 登录和前端数据加载问题
- 修复登录接口 response_model 导致 2FA 返回格式验证失败(500错误) - 修复 showProfile 未调用 loadTwofaStatus 导致 2FA 状态一直显示加载中 - 修复 hideOthers 默认值为 false 确保管理员可查看所有数据 - 删除重复的 doLogin 函数 - 删除残留的代码片段修复 JavaScript 语法错误
This commit is contained in:
@@ -35,7 +35,7 @@ async def register(req: RegisterRequest, db: AsyncSession = Depends(get_db)):
|
||||
return RegisterResponse(message="注册请求已提交,等待管理员审批")
|
||||
|
||||
|
||||
@router.post("/login", response_model=TokenResponse)
|
||||
@router.post("/login")
|
||||
async def login(
|
||||
req: LoginRequest,
|
||||
request: Request,
|
||||
@@ -81,6 +81,17 @@ async def login(
|
||||
if user.status == "disabled":
|
||||
raise HTTPException(status_code=403, detail="账户已被禁用")
|
||||
clear_login_failures(ip, account)
|
||||
|
||||
# 检查是否启用 2FA
|
||||
if user.two_factor_enabled and user.two_factor_secret:
|
||||
# 生成临时 token,等待 2FA 验证
|
||||
from app.core.security import create_token
|
||||
temp_token = create_token(
|
||||
{"sub": str(user.id), "type": "2fa_pending"},
|
||||
timedelta(minutes=5)
|
||||
)
|
||||
return {"require_2fa": True, "temp_token": temp_token}
|
||||
|
||||
return TokenResponse(
|
||||
access_token=create_access_token(user.id),
|
||||
refresh_token=create_refresh_token(user.id),
|
||||
|
||||
153
backend/app/api/twofa.py
Normal file
153
backend/app/api/twofa.py
Normal file
@@ -0,0 +1,153 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
from pydantic import BaseModel
|
||||
import pyotp
|
||||
import qrcode
|
||||
import io
|
||||
import base64
|
||||
from app.core.database import get_db
|
||||
from app.core.deps import get_current_user
|
||||
from app.models.models import User
|
||||
|
||||
router = APIRouter(prefix="/auth/2fa", tags=["双因素认证"])
|
||||
|
||||
|
||||
class TwoFASetupResponse(BaseModel):
|
||||
secret: str
|
||||
qr_code: str # base64 encoded QR code image
|
||||
otpauth_url: str
|
||||
|
||||
|
||||
class TwoFAVerifyRequest(BaseModel):
|
||||
code: str
|
||||
|
||||
|
||||
class TwoFALoginRequest(BaseModel):
|
||||
temp_token: str
|
||||
code: str
|
||||
|
||||
|
||||
@router.post("/setup", response_model=TwoFASetupResponse)
|
||||
async def setup_2fa(
|
||||
user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""生成 2FA 密钥和二维码"""
|
||||
secret = pyotp.random_base32()
|
||||
|
||||
totp = pyotp.TOTP(secret)
|
||||
otpauth_url = totp.provisioning_uri(
|
||||
name=user.email,
|
||||
issuer_name="债务管理系统"
|
||||
)
|
||||
|
||||
# 生成 QR 码
|
||||
qr = qrcode.QRCode(version=1, box_size=10, border=5)
|
||||
qr.add_data(otpauth_url)
|
||||
qr.make(fit=True)
|
||||
img = qr.make_image(fill_color="black", back_color="white")
|
||||
|
||||
# 转换为 base64
|
||||
buffer = io.BytesIO()
|
||||
img.save(buffer, format="PNG")
|
||||
qr_base64 = base64.b64encode(buffer.getvalue()).decode()
|
||||
|
||||
# 临时存储 secret(验证后才会真正保存)
|
||||
user.two_factor_secret = secret
|
||||
await db.commit()
|
||||
|
||||
return TwoFASetupResponse(
|
||||
secret=secret,
|
||||
qr_code=f"data:image/png;base64,{qr_base64}",
|
||||
otpauth_url=otpauth_url
|
||||
)
|
||||
|
||||
|
||||
@router.post("/verify")
|
||||
async def verify_2fa_setup(
|
||||
req: TwoFAVerifyRequest,
|
||||
user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""验证 2FA 设置(首次验证后启用)"""
|
||||
if not user.two_factor_secret:
|
||||
raise HTTPException(status_code=400, detail="请先执行 2FA 设置")
|
||||
|
||||
totp = pyotp.TOTP(user.two_factor_secret)
|
||||
if not totp.verify(req.code, valid_window=1):
|
||||
raise HTTPException(status_code=400, detail="验证码错误")
|
||||
|
||||
# 启用 2FA
|
||||
user.two_factor_enabled = 1
|
||||
await db.commit()
|
||||
|
||||
return {"message": "双因素认证已启用"}
|
||||
|
||||
|
||||
@router.post("/disable")
|
||||
async def disable_2fa(
|
||||
req: TwoFAVerifyRequest,
|
||||
user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""禁用 2FA(需要验证当前验证码)"""
|
||||
if not user.two_factor_enabled:
|
||||
raise HTTPException(status_code=400, detail="未启用双因素认证")
|
||||
|
||||
if not user.two_factor_secret:
|
||||
raise HTTPException(status_code=400, detail="2FA 配置异常")
|
||||
|
||||
totp = pyotp.TOTP(user.two_factor_secret)
|
||||
if not totp.verify(req.code, valid_window=1):
|
||||
raise HTTPException(status_code=400, detail="验证码错误")
|
||||
|
||||
user.two_factor_secret = None
|
||||
user.two_factor_enabled = 0
|
||||
await db.commit()
|
||||
|
||||
return {"message": "双因素认证已禁用"}
|
||||
|
||||
|
||||
@router.post("/verify-login")
|
||||
async def verify_2fa_login(
|
||||
req: TwoFALoginRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""验证 2FA 登录(密码验证后,2FA 验证前)"""
|
||||
from app.core.security import decode_token, create_access_token, create_refresh_token
|
||||
|
||||
# 验证临时 token
|
||||
payload = decode_token(req.temp_token)
|
||||
if not payload or payload.get("type") != "2fa_pending":
|
||||
raise HTTPException(status_code=401, detail="无效的验证请求")
|
||||
|
||||
user_id = int(payload["sub"])
|
||||
result = await db.execute(select(User).where(User.id == user_id))
|
||||
user = result.scalar_one_or_none()
|
||||
|
||||
if not user or not user.two_factor_secret:
|
||||
raise HTTPException(status_code=401, detail="用户状态异常")
|
||||
|
||||
# 验证 2FA
|
||||
totp = pyotp.TOTP(user.two_factor_secret)
|
||||
if not totp.verify(req.code, valid_window=1):
|
||||
raise HTTPException(status_code=401, detail="验证码错误")
|
||||
|
||||
# 返回正式 token
|
||||
return {
|
||||
"access_token": create_access_token(user.id),
|
||||
"refresh_token": create_refresh_token(user.id),
|
||||
"token_type": "bearer"
|
||||
}
|
||||
|
||||
|
||||
@router.get("/status")
|
||||
async def get_2fa_status(
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
"""获取 2FA 状态"""
|
||||
return {
|
||||
"enabled": bool(user.two_factor_enabled),
|
||||
"has_secret": bool(user.two_factor_secret)
|
||||
}
|
||||
@@ -6,7 +6,7 @@ from app.core.config import get_settings
|
||||
from app.core.database import engine, async_session
|
||||
from app.models.models import Base, User, Debt, RepaymentPlan, RepaymentRecord, Group
|
||||
from app.core.security import hash_password
|
||||
from app.api import auth, debts, repayments, admin, loans, notifications
|
||||
from app.api import auth, debts, repayments, admin, loans, notifications, twofa
|
||||
from sqlalchemy import select, text
|
||||
|
||||
settings = get_settings()
|
||||
@@ -52,10 +52,23 @@ async def migrate_account_names():
|
||||
|
||||
|
||||
async def migrate_add_real_email():
|
||||
async with engine.begin() as conn:
|
||||
try:
|
||||
await conn.execute(text(
|
||||
"ALTER TABLE users ADD COLUMN IF NOT EXISTS real_email VARCHAR(255) DEFAULT ''"
|
||||
))
|
||||
except Exception:
|
||||
pass # 表不存在时跳过,由 seed_admin 创建
|
||||
|
||||
async def migrate_add_2fa():
|
||||
async with engine.begin() as conn:
|
||||
await conn.execute(text(
|
||||
"ALTER TABLE users ADD COLUMN IF NOT EXISTS real_email VARCHAR(255) DEFAULT ''"
|
||||
"ALTER TABLE users ADD COLUMN IF NOT EXISTS two_factor_secret VARCHAR(32)"
|
||||
))
|
||||
await conn.execute(text(
|
||||
"ALTER TABLE users ADD COLUMN IF NOT EXISTS two_factor_enabled INTEGER DEFAULT 0"
|
||||
))
|
||||
|
||||
|
||||
|
||||
async def migrate_debt_data():
|
||||
@@ -98,6 +111,7 @@ async def migrate_debt_data():
|
||||
async def lifespan(app: FastAPI):
|
||||
await seed_admin()
|
||||
await migrate_add_real_email()
|
||||
await migrate_add_2fa()
|
||||
await migrate_account_names()
|
||||
await migrate_debt_data()
|
||||
from app.services.scheduler import start_scheduler, check_upcoming_repayments
|
||||
@@ -139,6 +153,7 @@ app.include_router(debts.router, prefix=settings.API_V1_PREFIX)
|
||||
app.include_router(repayments.router, prefix=settings.API_V1_PREFIX)
|
||||
app.include_router(admin.router, prefix=settings.API_V1_PREFIX)
|
||||
app.include_router(notifications.router, prefix=settings.API_V1_PREFIX)
|
||||
app.include_router(twofa.router, prefix=settings.API_V1_PREFIX)
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
|
||||
@@ -73,6 +73,8 @@ class User(Base):
|
||||
status = Column(String(20), default=UserStatus.ACTIVE.value)
|
||||
group_id = Column(Integer, ForeignKey("groups.id"), nullable=True)
|
||||
created_at = Column(DateTime, default=lambda: datetime.utcnow())
|
||||
two_factor_secret = Column(String(32), nullable=True)
|
||||
two_factor_enabled = Column(Integer, default=0)
|
||||
|
||||
group = relationship("Group", back_populates="members")
|
||||
debts = relationship("Debt", back_populates="user")
|
||||
|
||||
@@ -14,3 +14,6 @@ httpx==0.26.0
|
||||
python-dateutil==2.8.2
|
||||
apscheduler==3.10.4
|
||||
aiosmtplib==3.0.1
|
||||
pyotp==2.9.0
|
||||
qrcode==7.4.2
|
||||
pillow==10.2.0
|
||||
|
||||
Reference in New Issue
Block a user