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"""

邮箱变更验证码

Hi {user.nickname or user.email},

您正在修改绑定邮箱,验证码如下:

{code}

5 分钟内有效

如非本人操作,请忽略此邮件。

""" 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"""

密码重置验证码

Hi {nickname},

您正在重置密码,验证码如下:

{code}

{RESET_CODE_TTL_MINUTES} 分钟内有效

如非本人操作,请忽略此邮件。

—— 债务管理系统

""" @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}