v2.1: 新增通知提醒功能和安全增强

功能:
- 通知提醒系统: 还款日提醒、逾期提醒、系统消息横幅、登录时通知
- 通知中心UI: 铃铛图标 + 下拉列表 + 未读徽章
- 定时任务: APScheduler 每小时检查即将到期的还款
- 邮件通知: SMTP 发送还款提醒和安全警告
- 异常登录通知: 3/5/10次失败触发网页和邮件通知

安全:
- 登录安全告警: 异常登录次数达到阈值时发送通知
- 密码重置链接: 一次性使用, 5分钟有效期
- 登录频率限制: IP级和账户级限制

技术:
- 后端: FastAPI + SQLAlchemy + PostgreSQL
- 前端: 纯 HTML/CSS/JS
- 部署: Docker Compose (新增 build 配置)
This commit is contained in:
OP
2026-06-28 18:06:56 +08:00
commit ffca168dbe
41 changed files with 4834 additions and 0 deletions

337
backend/app/api/auth.py Normal file
View File

@@ -0,0 +1,337 @@
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 result.scalar_one_or_none():
raise HTTPException(status_code=400, detail="账号已注册")
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
ip = request.client.host if request.client else "unknown"
account = req.account.strip().lower()
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="User not found")
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}