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 PYTHONDONTWRITEBYTECODE=1
ENV PYTHONUNBUFFERED=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 . COPY requirements.txt .
RUN pip install --no-cache-dir -r 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.database import get_db
from app.core.dependencies import get_current_token_payload from app.core.dependencies import get_current_token_payload
from app.core.responses import api_success 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.auth_service import AuthService
from app.services.captcha_service import CaptchaService
router = APIRouter() router = APIRouter()
@router.get("/captcha")
def captcha() -> dict:
return api_success(CaptchaResponse.model_validate(CaptchaService.create()).model_dump())
@router.post("/sms/send") @router.post("/sms/send")
def send_sms(payload: SendSmsRequest, db: Session = Depends(get_db)) -> dict: 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="发送成功") return api_success(message="发送成功")

View File

@@ -9,6 +9,8 @@ from app.schemas.user import UserProfile
class SendSmsRequest(BaseModel): class SendSmsRequest(BaseModel):
phone: str = Field(min_length=11, max_length=20) 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): class LoginRequest(BaseModel):
@@ -20,3 +22,9 @@ class LoginResponse(BaseModel):
token: str token: str
expiredAt: datetime expiredAt: datetime
user: UserProfile 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.core.security import create_access_token
from app.models.user import User from app.models.user import User
from app.services.captcha_service import CaptchaService
from app.services.sms_code_service import SmsCodeService from app.services.sms_code_service import SmsCodeService
@@ -15,7 +16,8 @@ class AuthService:
_revoked_token_jti: set[str] = set() _revoked_token_jti: set[str] = set()
@classmethod @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) user = cls._get_existing_user(db, phone)
cls._ensure_user_can_login(user) cls._ensure_user_can_login(user)
SmsCodeService.send_code(db, phone) 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 openpyxl>=3.1.5
python-multipart>=0.0.20 python-multipart>=0.0.20
alibabacloud_dysmsapi20170525>=4.1.2 alibabacloud_dysmsapi20170525>=4.1.2
Pillow>=11.0.0

View File

@@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref } from "vue"; import { onMounted, ref } from "vue";
import { api, saveToken } from "../services/api"; import { api, saveToken } from "../services/api";
import type { UserProfile } from "../types/api"; import type { UserProfile } from "../types/api";
@@ -8,18 +8,46 @@ const emit = defineEmits<{
}>(); }>();
const phone = ref(""); const phone = ref("");
const code = ref("123456"); const code = ref("");
const captchaId = ref("");
const captchaCode = ref("");
const captchaImage = ref("");
const loading = ref(false); const loading = ref(false);
const message = ref("开发阶段验证码默认 123456"); const captchaLoading = ref(false);
const message = ref("请先完成图形验证码,再获取短信验证码。");
onMounted(() => {
void loadCaptcha();
});
async function loadCaptcha() {
captchaLoading.value = true;
try {
const result = await api.captcha();
captchaId.value = result.captchaId;
captchaImage.value = result.imageBase64;
captchaCode.value = "";
} catch (error) {
message.value = error instanceof Error ? error.message : "图形验证码加载失败";
} finally {
captchaLoading.value = false;
}
}
async function sendCode() { async function sendCode() {
if (!captchaId.value || captchaCode.value.length < 4) {
message.value = "请先输入图形验证码";
return;
}
loading.value = true; loading.value = true;
message.value = ""; message.value = "";
try { try {
await api.sendSms(phone.value); await api.sendSms(phone.value, captchaId.value, captchaCode.value);
message.value = "验证码已发送,开发阶段默认 123456"; message.value = "短信验证码已发送,请查收。";
await loadCaptcha();
} catch (error) { } catch (error) {
message.value = error instanceof Error ? error.message : "发送失败"; message.value = error instanceof Error ? error.message : "发送失败";
await loadCaptcha();
} finally { } finally {
loading.value = false; loading.value = false;
} }
@@ -52,10 +80,26 @@ async function login() {
</label> </label>
<label> <label>
验证码 图形验证码
<div class="captcha-row">
<input v-model="captchaCode" inputmode="text" maxlength="8" placeholder="请输入图形验证码" />
<button type="button" class="captcha-image" :disabled="captchaLoading" @click="loadCaptcha">
<img v-if="captchaImage" :src="captchaImage" alt="图形验证码" />
<span v-else>{{ captchaLoading ? "加载中" : "刷新" }}</span>
</button>
</div>
</label>
<label>
短信验证码
<div class="code-row"> <div class="code-row">
<input v-model="code" inputmode="numeric" maxlength="8" placeholder="验证码" /> <input v-model="code" inputmode="numeric" maxlength="8" placeholder="请输入短信验证码" />
<button type="button" class="secondary" :disabled="loading || phone.length < 11" @click="sendCode"> <button
type="button"
class="secondary"
:disabled="loading || phone.length < 11 || captchaCode.length < 4"
@click="sendCode"
>
发送 发送
</button> </button>
</div> </div>

View File

@@ -1,4 +1,4 @@
import type { ApiResponse, ChatMessage, ChatSession, LoginResult, UserProfile } from "../types/api"; import type { ApiResponse, CaptchaResult, ChatMessage, ChatSession, LoginResult, UserProfile } from "../types/api";
const API_BASE = import.meta.env.VITE_API_BASE_URL ?? "http://127.0.0.1:8100/api"; const API_BASE = import.meta.env.VITE_API_BASE_URL ?? "http://127.0.0.1:8100/api";
const TOKEN_KEY = "ai-kb-user-token"; const TOKEN_KEY = "ai-kb-user-token";
@@ -31,7 +31,9 @@ async function request<T>(path: string, init: RequestInit = {}): Promise<T> {
} }
export const api = { export const api = {
sendSms: (phone: string) => request<null>("/auth/sms/send", { method: "POST", body: JSON.stringify({ phone }) }), captcha: () => request<CaptchaResult>("/auth/captcha"),
sendSms: (phone: string, captchaId: string, captchaCode: string) =>
request<null>("/auth/sms/send", { method: "POST", body: JSON.stringify({ phone, captchaId, captchaCode }) }),
login: (phone: string, code: string) => login: (phone: string, code: string) =>
request<LoginResult>("/auth/login", { method: "POST", body: JSON.stringify({ phone, code }) }), request<LoginResult>("/auth/login", { method: "POST", body: JSON.stringify({ phone, code }) }),
logout: () => request<null>("/auth/logout", { method: "POST", body: JSON.stringify({}) }), logout: () => request<null>("/auth/logout", { method: "POST", body: JSON.stringify({}) }),

View File

@@ -117,6 +117,33 @@ button {
gap: 10px; gap: 10px;
} }
.captcha-row {
display: grid;
grid-template-columns: minmax(0, 1fr) 128px;
gap: 10px;
}
.captcha-image {
height: 48px;
padding: 0;
overflow: hidden;
border: 1px solid #cad8d2;
border-radius: 14px;
background: #ffffff;
}
.captcha-image img {
width: 100%;
height: 100%;
display: block;
object-fit: cover;
}
.captcha-image span {
color: #5f746c;
font-size: 13px;
}
.primary, .primary,
.secondary, .secondary,
.send-btn, .send-btn,

View File

@@ -20,6 +20,12 @@ export interface LoginResult {
user: UserProfile; user: UserProfile;
} }
export interface CaptchaResult {
captchaId: string;
imageBase64: string;
expiresInSeconds: number;
}
export interface ChatSession { export interface ChatSession {
id: number; id: number;
title: string; title: string;