v2.1: 新增通知提醒功能和安全增强
功能: - 通知提醒系统: 还款日提醒、逾期提醒、系统消息横幅、登录时通知 - 通知中心UI: 铃铛图标 + 下拉列表 + 未读徽章 - 定时任务: APScheduler 每小时检查即将到期的还款 - 邮件通知: SMTP 发送还款提醒和安全警告 - 异常登录通知: 3/5/10次失败触发网页和邮件通知 安全: - 登录安全告警: 异常登录次数达到阈值时发送通知 - 密码重置链接: 一次性使用, 5分钟有效期 - 登录频率限制: IP级和账户级限制 技术: - 后端: FastAPI + SQLAlchemy + PostgreSQL - 前端: 纯 HTML/CSS/JS - 部署: Docker Compose (新增 build 配置)
This commit is contained in:
0
backend/app/core/__init__.py
Normal file
0
backend/app/core/__init__.py
Normal file
32
backend/app/core/config.py
Normal file
32
backend/app/core/config.py
Normal file
@@ -0,0 +1,32 @@
|
||||
from pydantic_settings import BaseSettings
|
||||
from functools import lru_cache
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
PROJECT_NAME: str = "债务管理系统"
|
||||
API_V1_PREFIX: str = "/api/v1"
|
||||
DATABASE_URL: str = "postgresql+asyncpg://postgres:postgres@db:5432/debt_manager"
|
||||
REDIS_URL: str = "redis://redis:6379/0"
|
||||
SECRET_KEY: str = "your-secret-key-change-in-production"
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES: int = 30
|
||||
REFRESH_TOKEN_EXPIRE_DAYS: int = 7
|
||||
CORS_ORIGINS: list[str] = ["http://localhost", "http://localhost:80", "http://localhost:806"]
|
||||
ADMIN_EMAIL: str = "admin@example.com"
|
||||
ADMIN_PASSWORD: str = "admin123"
|
||||
|
||||
SMTP_HOST: str = ""
|
||||
SMTP_PORT: int = 465
|
||||
SMTP_USER: str = ""
|
||||
SMTP_PASSWORD: str = ""
|
||||
SMTP_FROM: str = ""
|
||||
SMTP_USE_TLS: bool = True
|
||||
NOTIFICATION_CHECK_INTERVAL_MINUTES: int = 60
|
||||
REMINDER_DAYS_BEFORE: int = 7
|
||||
|
||||
class Config:
|
||||
env_file = ".env"
|
||||
|
||||
|
||||
@lru_cache()
|
||||
def get_settings():
|
||||
return Settings()
|
||||
14
backend/app/core/database.py
Normal file
14
backend/app/core/database.py
Normal file
@@ -0,0 +1,14 @@
|
||||
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker
|
||||
from app.core.config import get_settings
|
||||
|
||||
settings = get_settings()
|
||||
engine = create_async_engine(settings.DATABASE_URL, echo=False)
|
||||
async_session = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
|
||||
|
||||
|
||||
async def get_db():
|
||||
async with async_session() as session:
|
||||
try:
|
||||
yield session
|
||||
finally:
|
||||
await session.close()
|
||||
31
backend/app/core/deps.py
Normal file
31
backend/app/core/deps.py
Normal file
@@ -0,0 +1,31 @@
|
||||
from fastapi import Depends, HTTPException, status
|
||||
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
from app.core.database import get_db
|
||||
from app.core.security import decode_token
|
||||
from app.models.models import User
|
||||
|
||||
security = HTTPBearer()
|
||||
|
||||
|
||||
async def get_current_user(
|
||||
credentials: HTTPAuthorizationCredentials = Depends(security),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> User:
|
||||
token = credentials.credentials
|
||||
payload = decode_token(token)
|
||||
if not payload or payload.get("type") != "access":
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid 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 or user.status == "disabled":
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="User not found or disabled")
|
||||
return user
|
||||
|
||||
|
||||
async def require_admin(user: User = Depends(get_current_user)) -> User:
|
||||
if user.role != "admin":
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin required")
|
||||
return user
|
||||
68
backend/app/core/rate_limit.py
Normal file
68
backend/app/core/rate_limit.py
Normal file
@@ -0,0 +1,68 @@
|
||||
import time
|
||||
from collections import defaultdict
|
||||
from fastapi import Request, HTTPException
|
||||
|
||||
MAX_FAILURES = 10
|
||||
WINDOW_SECONDS = 300
|
||||
ALERT_THRESHOLDS = [3, 5, 10]
|
||||
|
||||
_login_attempts: dict[str, list[float]] = defaultdict(list)
|
||||
_account_failures: dict[str, list[float]] = defaultdict(list)
|
||||
_account_notified: dict[str, set[int]] = defaultdict(set)
|
||||
|
||||
|
||||
def record_login_failure(ip: str, account: str = ""):
|
||||
now = time.time()
|
||||
_login_attempts[ip] = [t for t in _login_attempts[ip] if now - t < WINDOW_SECONDS]
|
||||
_login_attempts[ip].append(now)
|
||||
if account:
|
||||
_account_failures[account] = [t for t in _account_failures[account] if now - t < WINDOW_SECONDS]
|
||||
_account_failures[account].append(now)
|
||||
|
||||
|
||||
def clear_login_failures(ip: str, account: str = ""):
|
||||
_login_attempts.pop(ip, None)
|
||||
if account:
|
||||
_account_failures.pop(account, None)
|
||||
_account_notified.pop(account, None)
|
||||
|
||||
|
||||
def get_remaining_seconds(ip: str) -> int:
|
||||
now = time.time()
|
||||
_login_attempts[ip] = [t for t in _login_attempts[ip] if now - t < WINDOW_SECONDS]
|
||||
if not _login_attempts[ip]:
|
||||
return 0
|
||||
oldest = _login_attempts[ip][0]
|
||||
remaining = int(WINDOW_SECONDS - (now - oldest))
|
||||
return max(remaining, 0)
|
||||
|
||||
|
||||
def get_account_failure_count(account: str) -> int:
|
||||
now = time.time()
|
||||
_account_failures[account] = [t for t in _account_failures[account] if now - t < WINDOW_SECONDS]
|
||||
return len(_account_failures[account])
|
||||
|
||||
|
||||
def get_unnotified_threshold(account: str) -> int | None:
|
||||
now = time.time()
|
||||
_account_failures[account] = [t for t in _account_failures[account] if now - t < WINDOW_SECONDS]
|
||||
count = len(_account_failures[account])
|
||||
notified = _account_notified[account]
|
||||
for threshold in ALERT_THRESHOLDS:
|
||||
if count >= threshold and threshold not in notified:
|
||||
notified.add(threshold)
|
||||
return threshold
|
||||
return None
|
||||
|
||||
|
||||
def is_ip_blocked(ip: str) -> bool:
|
||||
return get_remaining_seconds(ip) > 0 and len(_login_attempts[ip]) >= MAX_FAILURES
|
||||
|
||||
|
||||
async def ip_rate_limit_middleware(request: Request, call_next):
|
||||
ip = request.client.host if request.client else "unknown"
|
||||
if is_ip_blocked(ip):
|
||||
remaining = get_remaining_seconds(ip)
|
||||
raise HTTPException(status_code=429, detail=f"登录失败次数过多,请 {remaining} 秒后再试")
|
||||
response = await call_next(request)
|
||||
return response
|
||||
39
backend/app/core/security.py
Normal file
39
backend/app/core/security.py
Normal file
@@ -0,0 +1,39 @@
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from jose import JWTError, jwt
|
||||
from passlib.context import CryptContext
|
||||
from app.core.config import get_settings
|
||||
|
||||
settings = get_settings()
|
||||
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||
|
||||
ALGORITHM = "HS256"
|
||||
|
||||
|
||||
def hash_password(password: str) -> str:
|
||||
return pwd_context.hash(password)
|
||||
|
||||
|
||||
def verify_password(plain: str, hashed: str) -> bool:
|
||||
return pwd_context.verify(plain, hashed)
|
||||
|
||||
|
||||
def create_token(data: dict, expires_delta: timedelta | None = None) -> str:
|
||||
to_encode = data.copy()
|
||||
expire = datetime.now(timezone.utc) + (expires_delta or timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES))
|
||||
to_encode.update({"exp": expire})
|
||||
return jwt.encode(to_encode, settings.SECRET_KEY, algorithm=ALGORITHM)
|
||||
|
||||
|
||||
def create_access_token(user_id: int) -> str:
|
||||
return create_token({"sub": str(user_id), "type": "access"}, timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES))
|
||||
|
||||
|
||||
def create_refresh_token(user_id: int) -> str:
|
||||
return create_token({"sub": str(user_id), "type": "refresh"}, timedelta(days=settings.REFRESH_TOKEN_EXPIRE_DAYS))
|
||||
|
||||
|
||||
def decode_token(token: str) -> dict | None:
|
||||
try:
|
||||
return jwt.decode(token, settings.SECRET_KEY, algorithms=[ALGORITHM])
|
||||
except JWTError:
|
||||
return None
|
||||
Reference in New Issue
Block a user