Your Name 3a6e25b5b1 feat: 添加密码重置功能
- 后端: 新增 POST /auth/reset-password 端点(邮箱+验证码+新密码)
- 后端: 新增 ResetPasswordRequest schema
- 前端: 新增 /forgot-password 页面(分步骤:输入邮箱→验证码+新密码→完成)
- 前端: 登录页添加"忘记密码?"链接

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-09 19:00:42 +08:00

392 lines
11 KiB
Python
Raw Permalink 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.

"""
认证 API
"""
from fastapi import APIRouter, Depends, HTTPException, Request, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.api.deps import get_current_user
from app.models.user import User
from app.schemas.auth import (
RegisterRequest,
LoginRequest,
LoginResponse,
RefreshTokenRequest,
RefreshTokenResponse,
ResetPasswordRequest,
SendEmailCodeRequest,
UserResponse,
)
from app.services.auth import (
get_user_by_email,
get_user_by_phone,
get_user_by_id,
create_user,
authenticate_user,
create_access_token,
create_refresh_token,
update_refresh_token,
decode_token,
get_user_organization_info,
hash_password,
)
from app.services.verification import generate_code, verify_code
from app.services.email import send_verification_email
from app.services.audit import log_action
router = APIRouter(prefix="/auth", tags=["认证"])
@router.post("/send-code")
async def send_email_code(
request: SendEmailCodeRequest,
req: Request,
db: AsyncSession = Depends(get_db),
):
"""
发送邮箱验证码
- purpose=register: 注册用,邮箱不能已被注册
- purpose=login: 登录用,邮箱必须已注册
- purpose=reset_password: 重置密码用,邮箱必须已注册
- 60秒内不可重复发送
"""
email = request.email
purpose = request.purpose
# 根据用途检查邮箱状态
existing = await get_user_by_email(db, email)
if purpose == "register":
if existing:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="该邮箱已被注册",
)
elif purpose in ("login", "reset_password"):
if not existing:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="该邮箱未注册",
)
# 生成验证码
code, error = generate_code(email, purpose)
if error:
raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail=error,
)
# 发送邮件
sent = send_verification_email(email, code, purpose)
if not sent:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="验证码发送失败,请稍后重试",
)
return {"message": "验证码已发送", "expires_in": 300}
@router.post("/register", response_model=LoginResponse, status_code=status.HTTP_201_CREATED)
async def register(
request: RegisterRequest,
req: Request,
db: AsyncSession = Depends(get_db),
):
"""
用户注册
- 需要先调用 /auth/send-code 获取邮箱验证码
- 验证码正确后完成注册
- 注册后自动登录,返回 Token
"""
# 验证邮箱验证码
if not verify_code(request.email, request.email_code, "register"):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="验证码错误或已过期",
)
# 检查邮箱是否已存在
existing = await get_user_by_email(db, request.email)
if existing:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="该邮箱已被注册",
)
# 检查手机号是否已存在
if request.phone:
existing = await get_user_by_phone(db, request.phone)
if existing:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="该手机号已被注册",
)
# 创建用户(邮箱已验证)
user = await create_user(
db=db,
email=request.email,
phone=request.phone,
password=request.password,
name=request.name,
role=request.role,
is_verified=True,
)
# 生成 Token
access_token = create_access_token(user.id)
refresh_token, refresh_expires_at = create_refresh_token(user.id)
# 保存 refresh token
await update_refresh_token(db, user, refresh_token, refresh_expires_at)
# 审计日志
await log_action(
db, "register", "user", user.id, user.id, user.name, user.role.value,
ip_address=req.client.host if req.client else None,
)
await db.commit()
# 获取组织信息
org_info = await get_user_organization_info(db, user)
return LoginResponse(
access_token=access_token,
refresh_token=refresh_token,
user=UserResponse(
id=user.id,
email=user.email,
phone=user.phone,
name=user.name,
avatar=user.avatar,
role=user.role,
is_verified=user.is_verified,
**org_info,
),
)
@router.post("/login", response_model=LoginResponse)
async def login(
request: LoginRequest,
req: Request,
db: AsyncSession = Depends(get_db),
):
"""
用户登录
- 支持邮箱+密码登录
- 支持邮箱+验证码登录(需先调用 /auth/send-code
"""
# 验证请求参数
if not request.email and not request.phone:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="请提供邮箱或手机号",
)
if not request.password and not request.email_code:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="请提供密码或验证码",
)
user = None
# 验证码登录
if request.email_code and request.email:
if not verify_code(request.email, request.email_code, "login"):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="验证码错误或已过期",
)
user = await get_user_by_email(db, request.email)
if not user:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="用户不存在",
)
else:
# 密码登录
user = await authenticate_user(
db=db,
email=request.email,
phone=request.phone,
password=request.password,
)
if not user:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="邮箱/手机号或密码错误",
)
if not user.is_active:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="账号已被禁用",
)
# 生成 Token
access_token = create_access_token(user.id)
refresh_token, refresh_expires_at = create_refresh_token(user.id)
# 保存 refresh token
await update_refresh_token(db, user, refresh_token, refresh_expires_at)
# 审计日志
await log_action(
db, "login", "user", user.id, user.id, user.name, user.role.value,
ip_address=req.client.host if req.client else None,
)
await db.commit()
# 获取组织信息
org_info = await get_user_organization_info(db, user)
return LoginResponse(
access_token=access_token,
refresh_token=refresh_token,
user=UserResponse(
id=user.id,
email=user.email,
phone=user.phone,
name=user.name,
avatar=user.avatar,
role=user.role,
is_verified=user.is_verified,
**org_info,
),
)
@router.post("/refresh", response_model=RefreshTokenResponse)
async def refresh_token(
request: RefreshTokenRequest,
db: AsyncSession = Depends(get_db),
):
"""
刷新 Access Token
- 使用 refreshToken 获取新的 accessToken
- refreshToken 有效期 7 天
"""
# 解码 refresh token
payload = decode_token(request.refresh_token)
if not payload:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="无效的 refresh token",
)
if payload.get("type") != "refresh":
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="无效的 token 类型",
)
user_id = payload.get("sub")
if not user_id:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="无效的 token",
)
# 获取用户
user = await get_user_by_id(db, user_id)
if not user:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="用户不存在",
)
# 验证 refresh token 是否匹配
if user.refresh_token != request.refresh_token:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="refresh token 已失效",
)
if not user.is_active:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="账号已被禁用",
)
# 生成新的 access token
access_token = create_access_token(user.id)
return RefreshTokenResponse(access_token=access_token)
@router.post("/reset-password")
async def reset_password(
request: ResetPasswordRequest,
req: Request,
db: AsyncSession = Depends(get_db),
):
"""
重置密码
- 需要先调用 /auth/send-code (purpose=reset_password) 获取验证码
- 验证码正确后设置新密码
"""
# 验证验证码
if not verify_code(request.email, request.email_code, "reset_password"):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="验证码错误或已过期",
)
# 查找用户
user = await get_user_by_email(db, request.email)
if not user:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="该邮箱未注册",
)
# 更新密码
user.password_hash = hash_password(request.new_password)
# 审计日志
await log_action(
db, "reset_password", "user", user.id, user.id,
user.name, user.role.value,
ip_address=req.client.host if req.client else None,
)
await db.commit()
return {"message": "密码已重置,请使用新密码登录"}
@router.post("/logout")
async def logout(
req: Request,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""
退出登录
- 清除 refresh token使其失效
"""
current_user.refresh_token = None
current_user.refresh_token_expires_at = None
# 审计日志
await log_action(
db, "logout", "user", current_user.id, current_user.id,
current_user.name, current_user.role.value,
ip_address=req.client.host if req.client else None,
)
await db.commit()
return {"message": "已退出登录"}