- 后端: 新增 POST /auth/reset-password 端点(邮箱+验证码+新密码) - 后端: 新增 ResetPasswordRequest schema - 前端: 新增 /forgot-password 页面(分步骤:输入邮箱→验证码+新密码→完成) - 前端: 登录页添加"忘记密码?"链接 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
392 lines
11 KiB
Python
392 lines
11 KiB
Python
"""
|
||
认证 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": "已退出登录"}
|