diff --git a/ai_knowledge_base_v2/apps/backend/Dockerfile b/ai_knowledge_base_v2/apps/backend/Dockerfile index dbe907d..b16047b 100644 --- a/ai_knowledge_base_v2/apps/backend/Dockerfile +++ b/ai_knowledge_base_v2/apps/backend/Dockerfile @@ -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 diff --git a/ai_knowledge_base_v2/apps/backend/app/api/auth.py b/ai_knowledge_base_v2/apps/backend/app/api/auth.py index 1edd626..881a985 100644 --- a/ai_knowledge_base_v2/apps/backend/app/api/auth.py +++ b/ai_knowledge_base_v2/apps/backend/app/api/auth.py @@ -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="发送成功") diff --git a/ai_knowledge_base_v2/apps/backend/app/schemas/auth.py b/ai_knowledge_base_v2/apps/backend/app/schemas/auth.py index 55e146d..ddb0b5f 100644 --- a/ai_knowledge_base_v2/apps/backend/app/schemas/auth.py +++ b/ai_knowledge_base_v2/apps/backend/app/schemas/auth.py @@ -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 diff --git a/ai_knowledge_base_v2/apps/backend/app/services/auth_service.py b/ai_knowledge_base_v2/apps/backend/app/services/auth_service.py index 9f3b55a..8802cdd 100644 --- a/ai_knowledge_base_v2/apps/backend/app/services/auth_service.py +++ b/ai_knowledge_base_v2/apps/backend/app/services/auth_service.py @@ -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) diff --git a/ai_knowledge_base_v2/apps/backend/app/services/captcha_service.py b/ai_knowledge_base_v2/apps/backend/app/services/captcha_service.py new file mode 100644 index 0000000..c8011ce --- /dev/null +++ b/ai_knowledge_base_v2/apps/backend/app/services/captcha_service.py @@ -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() diff --git a/ai_knowledge_base_v2/apps/backend/requirements.txt b/ai_knowledge_base_v2/apps/backend/requirements.txt index 091f9cf..8569205 100644 --- a/ai_knowledge_base_v2/apps/backend/requirements.txt +++ b/ai_knowledge_base_v2/apps/backend/requirements.txt @@ -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 diff --git a/ai_knowledge_base_v2/apps/user-client/src/components/LoginPanel.vue b/ai_knowledge_base_v2/apps/user-client/src/components/LoginPanel.vue index 3570743..84dda0b 100644 --- a/ai_knowledge_base_v2/apps/user-client/src/components/LoginPanel.vue +++ b/ai_knowledge_base_v2/apps/user-client/src/components/LoginPanel.vue @@ -1,5 +1,5 @@