Add graphic captcha before user SMS login

This commit is contained in:
2026-07-09 12:09:10 +08:00
parent 0e2706a4f4
commit 34d6dd01c7
10 changed files with 234 additions and 13 deletions

View File

@@ -5,6 +5,10 @@ WORKDIR /app
ENV PYTHONDONTWRITEBYTECODE=1
ENV PYTHONUNBUFFERED=1
RUN apt-get update \
&& apt-get install -y --no-install-recommends fonts-dejavu-core \
&& rm -rf /var/lib/apt/lists/*
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

View File

@@ -6,15 +6,21 @@ from sqlalchemy.orm import Session
from app.core.database import get_db
from app.core.dependencies import get_current_token_payload
from app.core.responses import api_success
from app.schemas.auth import LoginRequest, LoginResponse, SendSmsRequest
from app.schemas.auth import CaptchaResponse, LoginRequest, LoginResponse, SendSmsRequest
from app.services.auth_service import AuthService
from app.services.captcha_service import CaptchaService
router = APIRouter()
@router.get("/captcha")
def captcha() -> dict:
return api_success(CaptchaResponse.model_validate(CaptchaService.create()).model_dump())
@router.post("/sms/send")
def send_sms(payload: SendSmsRequest, db: Session = Depends(get_db)) -> dict:
AuthService.send_sms_code(db, payload.phone)
AuthService.send_sms_code(db, payload.phone, payload.captchaId, payload.captchaCode)
return api_success(message="发送成功")

View File

@@ -9,6 +9,8 @@ from app.schemas.user import UserProfile
class SendSmsRequest(BaseModel):
phone: str = Field(min_length=11, max_length=20)
captchaId: str = Field(min_length=1, max_length=100)
captchaCode: str = Field(min_length=4, max_length=8)
class LoginRequest(BaseModel):
@@ -20,3 +22,9 @@ class LoginResponse(BaseModel):
token: str
expiredAt: datetime
user: UserProfile
class CaptchaResponse(BaseModel):
captchaId: str
imageBase64: str
expiresInSeconds: int

View File

@@ -8,6 +8,7 @@ from sqlalchemy.orm import Session
from app.core.security import create_access_token
from app.models.user import User
from app.services.captcha_service import CaptchaService
from app.services.sms_code_service import SmsCodeService
@@ -15,7 +16,8 @@ class AuthService:
_revoked_token_jti: set[str] = set()
@classmethod
def send_sms_code(cls, db: Session, phone: str) -> None:
def send_sms_code(cls, db: Session, phone: str, captcha_id: str, captcha_code: str) -> None:
CaptchaService.verify(captcha_id, captcha_code)
user = cls._get_existing_user(db, phone)
cls._ensure_user_can_login(user)
SmsCodeService.send_code(db, phone)

View File

@@ -0,0 +1,121 @@
from __future__ import annotations
import base64
from dataclasses import dataclass
from datetime import UTC, datetime, timedelta
from io import BytesIO
import logging
import random
import string
from uuid import uuid4
from fastapi import HTTPException, status
from PIL import Image, ImageDraw, ImageFilter, ImageFont
logger = logging.getLogger(__name__)
CAPTCHA_EXPIRES_SECONDS = 300
CAPTCHA_WIDTH = 168
CAPTCHA_HEIGHT = 64
CAPTCHA_LENGTH = 4
@dataclass
class CaptchaRecord:
code: str
expires_at: datetime
class CaptchaService:
_records: dict[str, CaptchaRecord] = {}
@classmethod
def create(cls) -> dict:
cls._cleanup_expired()
captcha_id = str(uuid4())
code = _random_code()
cls._records[captcha_id] = CaptchaRecord(
code=code,
expires_at=datetime.now(UTC) + timedelta(seconds=CAPTCHA_EXPIRES_SECONDS),
)
return {
"captchaId": captcha_id,
"imageBase64": _render_captcha_image(code),
"expiresInSeconds": CAPTCHA_EXPIRES_SECONDS,
}
@classmethod
def verify(cls, captcha_id: str, captcha_code: str) -> None:
record = cls._records.get(captcha_id)
if record is None:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="图形验证码不存在或已过期")
if record.expires_at < datetime.now(UTC):
cls._records.pop(captcha_id, None)
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="图形验证码已过期")
if record.code.lower() != captcha_code.strip().lower():
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="图形验证码错误")
cls._records.pop(captcha_id, None)
@classmethod
def _cleanup_expired(cls) -> None:
now = datetime.now(UTC)
expired_ids = [captcha_id for captcha_id, record in cls._records.items() if record.expires_at < now]
for captcha_id in expired_ids:
cls._records.pop(captcha_id, None)
def _random_code() -> str:
alphabet = "".join(ch for ch in string.ascii_uppercase + string.digits if ch not in "0O1IL")
return "".join(random.SystemRandom().choice(alphabet) for _ in range(CAPTCHA_LENGTH))
def _render_captcha_image(code: str) -> str:
image = Image.new("RGB", (CAPTCHA_WIDTH, CAPTCHA_HEIGHT), "#f7fbfa")
draw = ImageDraw.Draw(image)
rng = random.SystemRandom()
for _ in range(70):
x = rng.randint(0, CAPTCHA_WIDTH - 1)
y = rng.randint(0, CAPTCHA_HEIGHT - 1)
color = rng.choice(["#d7e5df", "#c7ddd5", "#e4ece9"])
draw.point((x, y), fill=color)
for _ in range(4):
start = (rng.randint(0, CAPTCHA_WIDTH // 2), rng.randint(0, CAPTCHA_HEIGHT))
end = (rng.randint(CAPTCHA_WIDTH // 2, CAPTCHA_WIDTH), rng.randint(0, CAPTCHA_HEIGHT))
draw.line([start, end], fill=rng.choice(["#b7cec5", "#cbdad5"]), width=1)
font = _load_font()
char_width = CAPTCHA_WIDTH // CAPTCHA_LENGTH
for index, char in enumerate(code):
layer = Image.new("RGBA", (char_width, CAPTCHA_HEIGHT), (255, 255, 255, 0))
layer_draw = ImageDraw.Draw(layer)
bbox = layer_draw.textbbox((0, 0), char, font=font)
text_width = bbox[2] - bbox[0]
text_height = bbox[3] - bbox[1]
x = (char_width - text_width) // 2
y = (CAPTCHA_HEIGHT - text_height) // 2 - 3
layer_draw.text((x, y), char, font=font, fill=rng.choice(["#0f735d", "#173f35", "#31564d"]))
layer = layer.rotate(rng.randint(-14, 14), resample=Image.Resampling.BICUBIC, expand=False)
image.paste(layer, (index * char_width, 0), layer)
image = image.filter(ImageFilter.SMOOTH)
output = BytesIO()
image.save(output, format="PNG")
encoded = base64.b64encode(output.getvalue()).decode("ascii")
return f"data:image/png;base64,{encoded}"
def _load_font():
for path in (
"/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf",
"/usr/local/share/fonts/DejaVuSans-Bold.ttf",
"/System/Library/Fonts/Supplemental/Arial Bold.ttf",
"/Library/Fonts/Arial Bold.ttf",
):
try:
return ImageFont.truetype(path, 38)
except OSError:
continue
logger.warning("Captcha font fallback to Pillow default font")
return ImageFont.load_default()

View File

@@ -13,3 +13,4 @@ redis>=5.0.0
openpyxl>=3.1.5
python-multipart>=0.0.20
alibabacloud_dysmsapi20170525>=4.1.2
Pillow>=11.0.0