feat: 实现邮箱验证码注册/登录功能
- 后端: 新增验证码服务(生成/存储/验证)和邮件发送服务(开发环境控制台输出) - 后端: 新增 POST /auth/send-code 端点,支持注册/登录/重置密码三种用途 - 后端: 注册流程要求邮箱验证码,验证通过后 is_verified=True - 后端: 登录支持邮箱+密码 或 邮箱+验证码 两种方式 - 前端: 注册页增加验证码输入框和获取验证码按钮(60秒倒计时) - 前端: 登录页增加密码登录/验证码登录双Tab切换 - 测试: conftest 添加 bypass_verification fixture,所有 367 测试通过 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
864af19011
commit
d4081345f7
+95
-23
@@ -13,6 +13,7 @@ from app.schemas.auth import (
|
||||
LoginResponse,
|
||||
RefreshTokenRequest,
|
||||
RefreshTokenResponse,
|
||||
SendEmailCodeRequest,
|
||||
UserResponse,
|
||||
)
|
||||
from app.services.auth import (
|
||||
@@ -27,11 +28,65 @@ from app.services.auth import (
|
||||
decode_token,
|
||||
get_user_organization_info,
|
||||
)
|
||||
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,
|
||||
@@ -41,24 +96,24 @@ async def register(
|
||||
"""
|
||||
用户注册
|
||||
|
||||
- 支持邮箱或手机号注册(至少提供一个)
|
||||
- 需要先调用 /auth/send-code 获取邮箱验证码
|
||||
- 验证码正确后完成注册
|
||||
- 注册后自动登录,返回 Token
|
||||
"""
|
||||
# 验证至少提供邮箱或手机号
|
||||
if not request.email and not request.phone:
|
||||
# 验证邮箱验证码
|
||||
if not verify_code(request.email, request.email_code, "register"):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="请提供邮箱或手机号",
|
||||
detail="验证码错误或已过期",
|
||||
)
|
||||
|
||||
# 检查邮箱是否已存在
|
||||
if request.email:
|
||||
existing = await get_user_by_email(db, request.email)
|
||||
if existing:
|
||||
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:
|
||||
@@ -69,7 +124,7 @@ async def register(
|
||||
detail="该手机号已被注册",
|
||||
)
|
||||
|
||||
# 创建用户
|
||||
# 创建用户(邮箱已验证)
|
||||
user = await create_user(
|
||||
db=db,
|
||||
email=request.email,
|
||||
@@ -77,6 +132,7 @@ async def register(
|
||||
password=request.password,
|
||||
name=request.name,
|
||||
role=request.role,
|
||||
is_verified=True,
|
||||
)
|
||||
|
||||
# 生成 Token
|
||||
@@ -122,8 +178,8 @@ async def login(
|
||||
"""
|
||||
用户登录
|
||||
|
||||
- 支持邮箱+密码 或 手机号+密码 登录
|
||||
- 返回 accessToken 和 refreshToken
|
||||
- 支持邮箱+密码登录
|
||||
- 支持邮箱+验证码登录(需先调用 /auth/send-code)
|
||||
"""
|
||||
# 验证请求参数
|
||||
if not request.email and not request.phone:
|
||||
@@ -132,19 +188,35 @@ async def login(
|
||||
detail="请提供邮箱或手机号",
|
||||
)
|
||||
|
||||
if not request.password:
|
||||
if not request.password and not request.email_code:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="请提供密码",
|
||||
detail="请提供密码或验证码",
|
||||
)
|
||||
|
||||
# 验证用户
|
||||
user = await authenticate_user(
|
||||
db=db,
|
||||
email=request.email,
|
||||
phone=request.phone,
|
||||
password=request.password,
|
||||
)
|
||||
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(
|
||||
|
||||
Reference in New Issue
Block a user