Files
debt-manager/backend/app/api/auth.py
OP 498d29dfa9 security: 全面安全加固 v2.1
严重修复:
- C1: JWT密钥生成64字节随机字符串替代硬编码占位符
- C2: 数据库密码改为随机强密码,移除宿主机端口映射
- C3: 管理员默认密码改为强密码

高危修复:
- H1: 移除数据库54326端口映射,仅内部网络访问
- H3: 添加X-Frame-Options/CSP/XSS-Protection等安全头
- H4: 前端所有innerHTML拼接处添加escapeHtml转义防XSS
- H5: SMTP密码在API响应中脱敏显示
- H6: 债务列表接口添加用户隔离,普通用户只能看自己的数据
- H7: 债务详情/计划/记录接口添加归属校验
- H8: 账户级暴力破解锁定(5次失败锁定5分钟)
- H9: 速率限制数据增加过期清理机制防内存泄漏

中危修复:
- M1: 注册/重置密码接口添加后端密码强度校验(最少8位)
- M4: 添加全局异常处理器,500错误不再暴露堆栈
- M5: Docker容器改为非root用户运行
- M6: 添加.dockerignore排除pyc和敏感文件
- M7: 管理员操作写入审计日志
- M8: CORS限制为实际使用的HTTP方法和头

其他:
- Nginx隐藏版本号(server_tokens off)
- 管理员角色修改添加枚举校验
- 密码重置验证码接口返回统一错误信息
2026-06-28 22:46:24 +08:00

342 lines
14 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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", response_model=TokenResponse)
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)
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 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.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}