- 修复登录接口 response_model 导致 2FA 返回格式验证失败(500错误) - 修复 showProfile 未调用 loadTwofaStatus 导致 2FA 状态一直显示加载中 - 修复 hideOthers 默认值为 false 确保管理员可查看所有数据 - 删除重复的 doLogin 函数 - 删除残留的代码片段修复 JavaScript 语法错误
438 lines
17 KiB
Python
438 lines
17 KiB
Python
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
||
from sqlalchemy.ext.asyncio import AsyncSession
|
||
from sqlalchemy import select
|
||
from pydantic import BaseModel
|
||
from datetime import datetime, timedelta
|
||
import secrets
|
||
from app.core.database import get_db
|
||
from app.core.security import hash_password, verify_password, create_access_token, create_refresh_token, decode_token
|
||
from app.models.models import User, Group, SystemSetting
|
||
from app.schemas.schemas import (
|
||
RegisterRequest, LoginRequest, TokenResponse,
|
||
RefreshRequest, UserProfile, ChangePasswordRequest,
|
||
ForgotPasswordRequest, VerifyResetCodeRequest, ResetPasswordRequest,
|
||
)
|
||
from app.core.deps import get_current_user
|
||
|
||
router = APIRouter(prefix="/auth", tags=["认证"])
|
||
|
||
|
||
class RegisterResponse(BaseModel):
|
||
message: str
|
||
|
||
|
||
@router.post("/register", response_model=RegisterResponse)
|
||
async def register(req: RegisterRequest, db: AsyncSession = Depends(get_db)):
|
||
account = req.account.strip().lower()
|
||
result = await db.execute(select(User).where(User.email == account))
|
||
if not result.scalar_one_or_none():
|
||
user = User(
|
||
email=account, password_hash=hash_password(req.password),
|
||
nickname=req.nickname, status="pending", group_id=req.group_id,
|
||
)
|
||
db.add(user)
|
||
await db.commit()
|
||
return RegisterResponse(message="注册请求已提交,等待管理员审批")
|
||
|
||
|
||
@router.post("/login")
|
||
async def login(
|
||
req: LoginRequest,
|
||
request: Request,
|
||
db: AsyncSession = Depends(get_db),
|
||
):
|
||
from app.core.rate_limit import record_login_failure, clear_login_failures, get_account_failure_count, get_unnotified_threshold, is_account_blocked, get_account_remaining_seconds
|
||
ip = request.client.host if request.client else "unknown"
|
||
account = req.account.strip().lower()
|
||
|
||
if is_account_blocked(account):
|
||
remaining = get_account_remaining_seconds(account)
|
||
raise HTTPException(status_code=429, detail=f"该账户已被锁定,请 {remaining} 秒后再试")
|
||
|
||
result = await db.execute(select(User).where(User.email == account))
|
||
user = result.scalar_one_or_none()
|
||
if not user or not verify_password(req.password, user.password_hash):
|
||
record_login_failure(ip, account)
|
||
count = get_account_failure_count(account)
|
||
threshold = get_unnotified_threshold(account)
|
||
if threshold and user:
|
||
from app.services.email_service import send_login_alert_email
|
||
from app.services.notification_service import create_notification
|
||
from app.models.models import NotificationType
|
||
from app.core.database import async_session
|
||
|
||
level_map = {3: "注意", 5: "警告", 10: "严重"}
|
||
level = level_map.get(threshold, "注意")
|
||
title = f"【安全{level}】异常登录 {count} 次"
|
||
message = f"您的账号在 5 分钟内出现 {count} 次异常登录尝试,来源 IP: {ip}。若非本人操作,请立即修改密码。"
|
||
|
||
async with async_session() as notif_db:
|
||
await create_notification(
|
||
notif_db, user.id, title, message,
|
||
NotificationType.ACCOUNT.value,
|
||
)
|
||
await notif_db.commit()
|
||
|
||
if user.real_email:
|
||
await send_login_alert_email(user, count, threshold, ip)
|
||
raise HTTPException(status_code=401, detail="账号或密码错误")
|
||
if user.status == "pending":
|
||
raise HTTPException(status_code=403, detail="账户正在等待管理员审批")
|
||
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),
|
||
)
|
||
|
||
|
||
@router.post("/refresh", response_model=TokenResponse)
|
||
async def refresh(req: RefreshRequest, db: AsyncSession = Depends(get_db)):
|
||
payload = decode_token(req.refresh_token)
|
||
if not payload or payload.get("type") != "refresh":
|
||
raise HTTPException(status_code=401, detail="Invalid refresh token")
|
||
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:
|
||
raise HTTPException(status_code=401, detail="Token 无效")
|
||
if user.status == "pending":
|
||
raise HTTPException(status_code=403, detail="账户正在等待管理员审批")
|
||
if user.status == "disabled":
|
||
raise HTTPException(status_code=403, detail="账户已被禁用")
|
||
return TokenResponse(
|
||
access_token=create_access_token(user.id),
|
||
refresh_token=create_refresh_token(user.id),
|
||
)
|
||
|
||
|
||
@router.get("/profile", response_model=UserProfile)
|
||
async def get_profile(user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
|
||
group_name = ""
|
||
if user.group_id:
|
||
result = await db.execute(select(Group.name).where(Group.id == user.group_id))
|
||
row = result.first()
|
||
if row:
|
||
group_name = row[0]
|
||
return UserProfile(
|
||
id=user.id, account=user.email, nickname=user.nickname,
|
||
real_email=user.real_email or "",
|
||
role=user.role, status=user.status, group_id=user.group_id,
|
||
group_name=group_name, created_at=user.created_at,
|
||
)
|
||
|
||
|
||
@router.get("/groups")
|
||
async def list_groups(db: AsyncSession = Depends(get_db)):
|
||
result = await db.execute(select(Group).where(Group.id.isnot(None)))
|
||
return [{"id": g.id, "name": g.name} for g in result.scalars().all()]
|
||
|
||
|
||
|
||
|
||
class EmailVerifyRequest(BaseModel):
|
||
email: str
|
||
|
||
class EmailChangeRequest(BaseModel):
|
||
code: str
|
||
new_email: str
|
||
class ProfileUpdate(BaseModel):
|
||
nickname: str | None = None
|
||
real_email: str | None = None
|
||
|
||
|
||
@router.put("/profile")
|
||
async def update_profile(
|
||
req: ProfileUpdate,
|
||
user: User = Depends(get_current_user),
|
||
db: AsyncSession = Depends(get_db),
|
||
):
|
||
if req.nickname is not None:
|
||
user.nickname = req.nickname
|
||
if req.real_email is not None:
|
||
user.real_email = req.real_email
|
||
await db.commit()
|
||
return {"message": "资料更新成功"}
|
||
|
||
|
||
|
||
|
||
@router.post("/verify-email")
|
||
async def send_email_verification(
|
||
req: EmailVerifyRequest,
|
||
user: User = Depends(get_current_user),
|
||
db: AsyncSession = Depends(get_db),
|
||
):
|
||
import secrets
|
||
from datetime import timedelta
|
||
code = f"{secrets.randbelow(1000000):06d}"
|
||
key = f"email_verify:{user.id}"
|
||
value = f"{req.email}|{code}|{(datetime.utcnow() + timedelta(minutes=5)).isoformat()}"
|
||
|
||
result = await db.execute(select(SystemSetting).where(SystemSetting.key == key))
|
||
setting = result.scalar_one_or_none()
|
||
if setting:
|
||
setting.value = value
|
||
else:
|
||
db.add(SystemSetting(key=key, value=value))
|
||
await db.commit()
|
||
|
||
if user.real_email:
|
||
from app.services.email_service import send_email
|
||
html = f"""
|
||
<div style="font-family:-apple-system,sans-serif;max-width:480px;margin:0 auto;padding:32px">
|
||
<h2 style="color:#0071e3;margin-bottom:16px">邮箱变更验证码</h2>
|
||
<p>Hi {user.nickname or user.email},</p>
|
||
<p>您正在修改绑定邮箱,验证码如下:</p>
|
||
<div style="background:#f5f5f7;border-radius:12px;padding:24px;margin:16px 0;text-align:center">
|
||
<p style="font-size:36px;font-weight:700;letter-spacing:8px;color:#1d1d1f;margin:0">{code}</p>
|
||
<p style="color:#6e6e73;font-size:12px;margin:8px 0 0 0">5 分钟内有效</p>
|
||
</div>
|
||
<p style="color:#6e6e73;font-size:13px">如非本人操作,请忽略此邮件。</p>
|
||
</div>
|
||
"""
|
||
await send_email(user.real_email, "【债务管理系统】邮箱变更验证码", html)
|
||
|
||
return {"message": "验证码已发送到当前邮箱"}
|
||
|
||
|
||
@router.post("/change-email")
|
||
async def change_email(
|
||
req: EmailChangeRequest,
|
||
user: User = Depends(get_current_user),
|
||
db: AsyncSession = Depends(get_db),
|
||
):
|
||
key = f"email_verify:{user.id}"
|
||
result = await db.execute(select(SystemSetting).where(SystemSetting.key == key))
|
||
setting = result.scalar_one_or_none()
|
||
if not setting:
|
||
raise HTTPException(status_code=400, detail="验证码已过期,请重新获取")
|
||
|
||
parts = setting.value.split("|")
|
||
if len(parts) != 3 or parts[1] != req.code:
|
||
raise HTTPException(status_code=400, detail="验证码错误")
|
||
|
||
try:
|
||
expires_at = datetime.fromisoformat(parts[2])
|
||
except ValueError:
|
||
raise HTTPException(status_code=400, detail="验证码格式错误")
|
||
|
||
if datetime.utcnow() > expires_at:
|
||
await db.delete(setting)
|
||
await db.commit()
|
||
raise HTTPException(status_code=400, detail="验证码已过期,请重新获取")
|
||
|
||
# Check if new email is already used
|
||
existing = await db.execute(select(User).where(User.real_email == req.new_email, User.id != user.id))
|
||
if existing.scalar_one_or_none():
|
||
raise HTTPException(status_code=400, detail="该邮箱已被其他账号使用")
|
||
|
||
user.real_email = req.new_email
|
||
await db.delete(setting)
|
||
await db.commit()
|
||
return {"message": "邮箱修改成功"}
|
||
|
||
@router.put("/password")
|
||
async def change_password(
|
||
req: ChangePasswordRequest,
|
||
user: User = Depends(get_current_user),
|
||
db: AsyncSession = Depends(get_db),
|
||
):
|
||
if not verify_password(req.old_password, user.password_hash):
|
||
raise HTTPException(status_code=400, detail="原密码错误")
|
||
user.password_hash = hash_password(req.new_password)
|
||
await db.commit()
|
||
from app.services.email_service import send_password_changed_email
|
||
await send_password_changed_email(user, "修改")
|
||
return {"message": "密码修改成功"}
|
||
|
||
|
||
RESET_CODE_TTL_MINUTES = 10
|
||
|
||
|
||
async def _find_user_by_account_or_email(db: AsyncSession, account: str) -> User | None:
|
||
account = account.strip().lower()
|
||
result = await db.execute(select(User).where(User.email == account))
|
||
user = result.scalar_one_or_none()
|
||
if user:
|
||
return user
|
||
result = await db.execute(select(User).where(User.real_email == account))
|
||
return result.scalar_one_or_none()
|
||
|
||
|
||
def _build_code_key(user_id: int) -> str:
|
||
return f"reset_code:{user_id}"
|
||
|
||
|
||
def _build_code_html(nickname: str, code: str) -> str:
|
||
return f"""
|
||
<div style="font-family:-apple-system,sans-serif;max-width:480px;margin:0 auto;padding:32px">
|
||
<h2 style="color:#0071e3;margin-bottom:16px">密码重置验证码</h2>
|
||
<p>Hi {nickname},</p>
|
||
<p>您正在重置密码,验证码如下:</p>
|
||
<div style="background:#f5f5f7;border-radius:12px;padding:24px;margin:16px 0;text-align:center">
|
||
<p style="font-size:36px;font-weight:700;letter-spacing:8px;color:#1d1d1f;margin:0">{code}</p>
|
||
<p style="color:#6e6e73;font-size:12px;margin:8px 0 0 0">{RESET_CODE_TTL_MINUTES} 分钟内有效</p>
|
||
</div>
|
||
<p style="color:#6e6e73;font-size:13px">如非本人操作,请忽略此邮件。</p>
|
||
<p style="color:#6e6e73;font-size:13px">—— 债务管理系统</p>
|
||
</div>
|
||
"""
|
||
|
||
|
||
@router.post("/forgot-password")
|
||
async def forgot_password(
|
||
req: ForgotPasswordRequest,
|
||
db: AsyncSession = Depends(get_db),
|
||
):
|
||
user = await _find_user_by_account_or_email(db, req.account)
|
||
if not user or not user.real_email:
|
||
return {"message": "如果该账号已绑定邮箱,验证码已发送"}
|
||
code = f"{secrets.randbelow(1000000):06d}"
|
||
key = _build_code_key(user.id)
|
||
result = await db.execute(select(SystemSetting).where(SystemSetting.key == key))
|
||
setting = result.scalar_one_or_none()
|
||
value = f"{code}|{(datetime.utcnow() + timedelta(minutes=RESET_CODE_TTL_MINUTES)).isoformat()}"
|
||
if setting:
|
||
setting.value = value
|
||
else:
|
||
db.add(SystemSetting(key=key, value=value))
|
||
await db.commit()
|
||
from app.services.email_service import send_email
|
||
html = _build_code_html(user.nickname or user.email, code)
|
||
await send_email(user.real_email, "【债务管理系统】密码重置验证码", html)
|
||
return {"message": "如果该账号已绑定邮箱,验证码已发送"}
|
||
|
||
|
||
@router.post("/verify-reset-code")
|
||
async def verify_reset_code(
|
||
req: VerifyResetCodeRequest,
|
||
db: AsyncSession = Depends(get_db),
|
||
):
|
||
user = await _find_user_by_account_or_email(db, req.account)
|
||
if not user:
|
||
raise HTTPException(status_code=400, detail="验证码错误或已过期,请重新获取")
|
||
key = _build_code_key(user.id)
|
||
result = await db.execute(select(SystemSetting).where(SystemSetting.key == key))
|
||
setting = result.scalar_one_or_none()
|
||
if not setting:
|
||
raise HTTPException(status_code=400, detail="验证码已过期,请重新获取")
|
||
parts = setting.value.split("|")
|
||
if len(parts) != 2 or parts[0] != req.code:
|
||
raise HTTPException(status_code=400, detail="验证码错误")
|
||
try:
|
||
expires_at = datetime.fromisoformat(parts[1])
|
||
except ValueError:
|
||
raise HTTPException(status_code=400, detail="验证码格式错误")
|
||
if datetime.utcnow() > expires_at:
|
||
raise HTTPException(status_code=400, detail="验证码已过期,请重新获取")
|
||
return {"message": "验证码正确"}
|
||
|
||
|
||
@router.post("/reset-password")
|
||
async def reset_password(
|
||
req: ResetPasswordRequest,
|
||
db: AsyncSession = Depends(get_db),
|
||
):
|
||
user = await _find_user_by_account_or_email(db, req.account)
|
||
if not user:
|
||
raise HTTPException(status_code=400, detail="验证码错误或已过期,请重新获取")
|
||
key = _build_code_key(user.id)
|
||
result = await db.execute(select(SystemSetting).where(SystemSetting.key == key))
|
||
setting = result.scalar_one_or_none()
|
||
if not setting:
|
||
raise HTTPException(status_code=400, detail="验证码已过期,请重新获取")
|
||
parts = setting.value.split("|")
|
||
if len(parts) != 2 or parts[0] != req.code:
|
||
raise HTTPException(status_code=400, detail="验证码错误")
|
||
try:
|
||
expires_at = datetime.fromisoformat(parts[1])
|
||
except ValueError:
|
||
raise HTTPException(status_code=400, detail="验证码格式错误")
|
||
if datetime.utcnow() > expires_at:
|
||
raise HTTPException(status_code=400, detail="验证码已过期,请重新获取")
|
||
user.password_hash = hash_password(req.new_password)
|
||
await db.delete(setting)
|
||
await db.commit()
|
||
from app.services.email_service import send_password_changed_email
|
||
await send_password_changed_email(user, "重置")
|
||
return {"message": "密码重置成功"}
|
||
|
||
|
||
class ResetByTokenRequest(BaseModel):
|
||
token: str
|
||
uid: int
|
||
new_password: str
|
||
|
||
|
||
@router.post("/reset-by-token")
|
||
async def reset_by_token(
|
||
req: ResetByTokenRequest,
|
||
db: AsyncSession = Depends(get_db),
|
||
):
|
||
result = await db.execute(select(User).where(User.id == req.uid))
|
||
user = result.scalar_one_or_none()
|
||
if not user:
|
||
raise HTTPException(status_code=400, detail="链接无效")
|
||
key = f"pwd_reset:{req.uid}"
|
||
result = await db.execute(select(SystemSetting).where(SystemSetting.key == key))
|
||
setting = result.scalar_one_or_none()
|
||
if not setting:
|
||
raise HTTPException(status_code=400, detail="链接已失效,请联系管理员重新发送")
|
||
parts = setting.value.split("|")
|
||
if len(parts) != 2 or parts[0] != req.token:
|
||
raise HTTPException(status_code=400, detail="链接无效")
|
||
try:
|
||
expires_at = datetime.fromisoformat(parts[1])
|
||
except ValueError:
|
||
raise HTTPException(status_code=400, detail="链接格式错误")
|
||
if datetime.utcnow() > expires_at:
|
||
await db.delete(setting)
|
||
await db.commit()
|
||
raise HTTPException(status_code=400, detail="链接已过期,请联系管理员重新发送")
|
||
user.password_hash = hash_password(req.new_password)
|
||
await db.delete(setting)
|
||
await db.commit()
|
||
from app.services.email_service import send_password_changed_email
|
||
await send_password_changed_email(user, "重置")
|
||
return {"message": "密码重置成功"}
|
||
|
||
|
||
@router.get("/check-reset-token")
|
||
async def check_reset_token(
|
||
token: str,
|
||
uid: int,
|
||
db: AsyncSession = Depends(get_db),
|
||
):
|
||
result = await db.execute(select(User).where(User.id == uid))
|
||
user = result.scalar_one_or_none()
|
||
if not user:
|
||
raise HTTPException(status_code=400, detail="链接无效")
|
||
key = f"pwd_reset:{uid}"
|
||
result = await db.execute(select(SystemSetting).where(SystemSetting.key == key))
|
||
setting = result.scalar_one_or_none()
|
||
if not setting:
|
||
raise HTTPException(status_code=400, detail="链接已失效")
|
||
parts = setting.value.split("|")
|
||
if len(parts) != 2 or parts[0] != token:
|
||
raise HTTPException(status_code=400, detail="链接无效")
|
||
try:
|
||
expires_at = datetime.fromisoformat(parts[1])
|
||
except ValueError:
|
||
raise HTTPException(status_code=400, detail="链接格式错误")
|
||
if datetime.utcnow() > expires_at:
|
||
raise HTTPException(status_code=400, detail="链接已过期")
|
||
return {"valid": True, "nickname": user.nickname or user.email}
|