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 import APIRouter, Depends, HTTPException, status
from fastapi.responses import Response
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select from sqlalchemy import select
from fastcrud import FastCRUD 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 utils.jwt import create_access_token
from config import app_settings from config import app_settings
from utils.redis_client import get_redis, set_token_in_redis 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 from middleware import get_current_user
router = APIRouter(prefix="/users", tags=["users"]) router = APIRouter(prefix="/users", tags=["users"])
@ -27,6 +29,19 @@ router = APIRouter(prefix="/users", tags=["users"])
user_crud = FastCRUD(User, UserResponse) 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) @router.post("/login", response_model=Token)
async def login( async def login(
user_data: UserLogin, user_data: UserLogin,
@ -34,6 +49,17 @@ async def login(
redis_conn: redis.Redis = Depends(get_redis), 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)) result = await db.execute(select(User).filter(User.username == user_data.username))
user = result.scalar_one_or_none() user = result.scalar_one_or_none()

View File

@ -25,4 +25,7 @@ alembic>=1.18.4
asyncpg>=0.31.0 asyncpg>=0.31.0
# Redis异步客户端 # Redis异步客户端
redis>=7.2.0 redis>=7.2.0
# 验证码生成
captcha>=0.7.1

View File

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