feat(auth): 添加验证码功能以增强登录安全性

实现验证码生成、存储和验证逻辑
- 新增验证码生成工具函数
- 在登录接口中添加验证码校验
- 添加获取验证码图片的API端点
- 更新用户登录schema包含验证码字段
- 添加captcha依赖到requirements.txt
This commit is contained in:
jayhgq 2026-04-28 17:42:00 +08:00
parent 1427cf4884
commit c98adab6ce
26 changed files with 112 additions and 1 deletions

View File

@ -0,0 +1,6 @@
---
alwaysApply: true
scene: git_message
---
在此处编写规则,自定义 AI 生成提交信息的风格。

View File

@ -1,4 +1,5 @@
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.responses import Response
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from fastcrud import FastCRUD
@ -19,6 +20,7 @@ from utils.password import verify_password, get_password_hash
from utils.jwt import create_access_token
from config import app_settings
from utils.redis_client import get_redis, set_token_in_redis
from utils.captcha import create_captcha, validate_captcha
from middleware import get_current_user
router = APIRouter(prefix="/users", tags=["users"])
@ -27,6 +29,19 @@ router = APIRouter(prefix="/users", tags=["users"])
user_crud = FastCRUD(User, UserResponse)
@router.get("/captcha")
async def get_captcha(redis_conn: redis.Redis = Depends(get_redis)):
"""获取验证码图片"""
# 创建验证码
captcha_id, image_bytes = await create_captcha(redis_conn)
# 返回验证码图片同时在响应头中返回验证码ID
response = Response(content=image_bytes, media_type="image/png")
response.headers["X-Captcha-ID"] = captcha_id
return response
@router.post("/login", response_model=Token)
async def login(
user_data: UserLogin,
@ -34,6 +49,17 @@ async def login(
redis_conn: redis.Redis = Depends(get_redis),
):
"""用户登录"""
# 验证验证码
is_captcha_valid = await validate_captcha(
redis_conn, user_data.captcha_id, user_data.captcha_code
)
if not is_captcha_valid:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="验证码错误或已过期",
headers={"WWW-Authenticate": "Bearer"},
)
# 查找用户
result = await db.execute(select(User).filter(User.username == user_data.username))
user = result.scalar_one_or_none()

View File

@ -26,3 +26,6 @@ asyncpg>=0.31.0
# Redis异步客户端
redis>=7.2.0
# 验证码生成
captcha>=0.7.1

View File

@ -47,6 +47,8 @@ class UserLogin(BaseModel):
username: str = Field(..., description="登录名")
password: str = Field(..., description="密码")
captcha_id: str = Field(..., description="验证码ID")
captcha_code: str = Field(..., description="验证码")
class UserResponse(BaseModel):

Binary file not shown.

View File

@ -0,0 +1,69 @@
from io import BytesIO
from captcha.image import ImageCaptcha
from uuid import uuid4
from utils.redis_client import get_redis, set_token_in_redis
import redis.asyncio as redis
import random
import string
def generate_captcha(length: int = 4) -> tuple[str, bytes]:
"""生成验证码图片和验证码文本"""
# 生成随机验证码
characters = string.digits + string.ascii_uppercase
captcha_text = ''.join(random.choices(characters, k=length))
# 生成验证码图片
image = ImageCaptcha(width=150, height=50)
image_bytes = image.generate(captcha_text)
# 将图片转换为字节流
buffer = BytesIO()
buffer.write(image_bytes.read())
buffer.seek(0)
return captcha_text, buffer.getvalue()
async def save_captcha_to_redis(redis_conn: redis.Redis, captcha_id: str, captcha_text: str, expire_seconds: int = 300):
"""将验证码保存到Redis"""
await redis_conn.set(f"captcha:{captcha_id}", captcha_text, ex=expire_seconds)
async def get_captcha_from_redis(redis_conn: redis.Redis, captcha_id: str) -> str | None:
"""从Redis获取验证码"""
return await redis_conn.get(f"captcha:{captcha_id}")
async def delete_captcha_from_redis(redis_conn: redis.Redis, captcha_id: str):
"""从Redis删除验证码"""
await redis_conn.delete(f"captcha:{captcha_id}")
async def validate_captcha(redis_conn: redis.Redis, captcha_id: str, user_input: str) -> bool:
"""验证验证码是否正确"""
stored_captcha = await get_captcha_from_redis(redis_conn, captcha_id)
if not stored_captcha:
return False
# 忽略大小写比较
is_valid = stored_captcha.upper() == user_input.upper()
# 无论验证成功与否,都删除验证码(防止重复使用)
await delete_captcha_from_redis(redis_conn, captcha_id)
return is_valid
async def create_captcha(redis_conn: redis.Redis) -> tuple[str, bytes]:
"""创建验证码并保存到Redis"""
# 生成验证码
captcha_text, image_bytes = generate_captcha()
# 生成唯一ID
captcha_id = str(uuid4())
# 保存到Redis有效期5分钟
await save_captcha_to_redis(redis_conn, captcha_id, captcha_text, 300)
return captcha_id, image_bytes

Binary file not shown.

After

Width:  |  Height:  |  Size: 628 B

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long