Add graphic captcha before user SMS login
This commit is contained in:
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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="发送成功")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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({}) }),
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user