Add graphic captcha before user SMS login
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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="发送成功")
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
@@ -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
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from "vue";
|
||||
import { onMounted, ref } from "vue";
|
||||
import { api, saveToken } from "../services/api";
|
||||
import type { UserProfile } from "../types/api";
|
||||
|
||||
@@ -8,18 +8,46 @@ const emit = defineEmits<{
|
||||
}>();
|
||||
|
||||
const phone = ref("");
|
||||
const code = ref("123456");
|
||||
const code = ref("");
|
||||
const captchaId = ref("");
|
||||
const captchaCode = ref("");
|
||||
const captchaImage = ref("");
|
||||
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() {
|
||||
if (!captchaId.value || captchaCode.value.length < 4) {
|
||||
message.value = "请先输入图形验证码";
|
||||
return;
|
||||
}
|
||||
loading.value = true;
|
||||
message.value = "";
|
||||
try {
|
||||
await api.sendSms(phone.value);
|
||||
message.value = "验证码已发送,开发阶段默认 123456";
|
||||
await api.sendSms(phone.value, captchaId.value, captchaCode.value);
|
||||
message.value = "短信验证码已发送,请查收。";
|
||||
await loadCaptcha();
|
||||
} catch (error) {
|
||||
message.value = error instanceof Error ? error.message : "发送失败";
|
||||
await loadCaptcha();
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
@@ -52,10 +80,26 @@ async function login() {
|
||||
</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">
|
||||
<input v-model="code" inputmode="numeric" maxlength="8" placeholder="验证码" />
|
||||
<button type="button" class="secondary" :disabled="loading || phone.length < 11" @click="sendCode">
|
||||
<input v-model="code" inputmode="numeric" maxlength="8" placeholder="请输入短信验证码" />
|
||||
<button
|
||||
type="button"
|
||||
class="secondary"
|
||||
:disabled="loading || phone.length < 11 || captchaCode.length < 4"
|
||||
@click="sendCode"
|
||||
>
|
||||
发送
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -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 TOKEN_KEY = "ai-kb-user-token";
|
||||
@@ -31,7 +31,9 @@ async function request<T>(path: string, init: RequestInit = {}): Promise<T> {
|
||||
}
|
||||
|
||||
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) =>
|
||||
request<LoginResult>("/auth/login", { method: "POST", body: JSON.stringify({ phone, code }) }),
|
||||
logout: () => request<null>("/auth/logout", { method: "POST", body: JSON.stringify({}) }),
|
||||
|
||||
@@ -117,6 +117,33 @@ button {
|
||||
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,
|
||||
.secondary,
|
||||
.send-btn,
|
||||
|
||||
@@ -20,6 +20,12 @@ export interface LoginResult {
|
||||
user: UserProfile;
|
||||
}
|
||||
|
||||
export interface CaptchaResult {
|
||||
captchaId: string;
|
||||
imageBase64: string;
|
||||
expiresInSeconds: number;
|
||||
}
|
||||
|
||||
export interface ChatSession {
|
||||
id: number;
|
||||
title: string;
|
||||
|
||||
Reference in New Issue
Block a user