Store login verification codes in Redis
This commit is contained in:
@@ -12,6 +12,8 @@ from uuid import uuid4
|
|||||||
from fastapi import HTTPException, status
|
from fastapi import HTTPException, status
|
||||||
from PIL import Image, ImageDraw, ImageFilter, ImageFont
|
from PIL import Image, ImageDraw, ImageFilter, ImageFont
|
||||||
|
|
||||||
|
from app.services.redis_client import get_sync_redis_client
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
CAPTCHA_EXPIRES_SECONDS = 300
|
CAPTCHA_EXPIRES_SECONDS = 300
|
||||||
@@ -34,10 +36,13 @@ class CaptchaService:
|
|||||||
cls._cleanup_expired()
|
cls._cleanup_expired()
|
||||||
captcha_id = str(uuid4())
|
captcha_id = str(uuid4())
|
||||||
code = _random_code()
|
code = _random_code()
|
||||||
cls._records[captcha_id] = CaptchaRecord(
|
if _set_redis_captcha(captcha_id, code):
|
||||||
code=code,
|
cls._records.pop(captcha_id, None)
|
||||||
expires_at=datetime.now(UTC) + timedelta(seconds=CAPTCHA_EXPIRES_SECONDS),
|
else:
|
||||||
)
|
cls._records[captcha_id] = CaptchaRecord(
|
||||||
|
code=code,
|
||||||
|
expires_at=datetime.now(UTC) + timedelta(seconds=CAPTCHA_EXPIRES_SECONDS),
|
||||||
|
)
|
||||||
return {
|
return {
|
||||||
"captchaId": captcha_id,
|
"captchaId": captcha_id,
|
||||||
"imageBase64": _render_captcha_image(code),
|
"imageBase64": _render_captcha_image(code),
|
||||||
@@ -46,6 +51,14 @@ class CaptchaService:
|
|||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def verify(cls, captcha_id: str, captcha_code: str) -> None:
|
def verify(cls, captcha_id: str, captcha_code: str) -> None:
|
||||||
|
redis_available, redis_code = _get_redis_captcha(captcha_id)
|
||||||
|
if redis_available and redis_code is not None:
|
||||||
|
if redis_code.lower() != captcha_code.strip().lower():
|
||||||
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="图形验证码错误")
|
||||||
|
_delete_redis_captcha(captcha_id)
|
||||||
|
cls._records.pop(captcha_id, None)
|
||||||
|
return
|
||||||
|
|
||||||
record = cls._records.get(captcha_id)
|
record = cls._records.get(captcha_id)
|
||||||
if record is None:
|
if record is None:
|
||||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="图形验证码不存在或已过期")
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="图形验证码不存在或已过期")
|
||||||
@@ -69,6 +82,43 @@ def _random_code() -> str:
|
|||||||
return "".join(random.SystemRandom().choice(alphabet) for _ in range(CAPTCHA_LENGTH))
|
return "".join(random.SystemRandom().choice(alphabet) for _ in range(CAPTCHA_LENGTH))
|
||||||
|
|
||||||
|
|
||||||
|
def _captcha_key(captcha_id: str) -> str:
|
||||||
|
return f"auth:captcha:{captcha_id}"
|
||||||
|
|
||||||
|
|
||||||
|
def _set_redis_captcha(captcha_id: str, code: str) -> bool:
|
||||||
|
client = get_sync_redis_client()
|
||||||
|
if client is None:
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
return bool(client.set(_captcha_key(captcha_id), code, ex=CAPTCHA_EXPIRES_SECONDS))
|
||||||
|
except Exception:
|
||||||
|
logger.warning("Redis captcha write failed for %s", captcha_id, exc_info=True)
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _get_redis_captcha(captcha_id: str) -> tuple[bool, str | None]:
|
||||||
|
client = get_sync_redis_client()
|
||||||
|
if client is None:
|
||||||
|
return False, None
|
||||||
|
try:
|
||||||
|
value = client.get(_captcha_key(captcha_id))
|
||||||
|
except Exception:
|
||||||
|
logger.warning("Redis captcha read failed for %s", captcha_id, exc_info=True)
|
||||||
|
return False, None
|
||||||
|
return True, value if isinstance(value, str) else None
|
||||||
|
|
||||||
|
|
||||||
|
def _delete_redis_captcha(captcha_id: str) -> None:
|
||||||
|
client = get_sync_redis_client()
|
||||||
|
if client is None:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
client.delete(_captcha_key(captcha_id))
|
||||||
|
except Exception:
|
||||||
|
logger.warning("Redis captcha delete failed for %s", captcha_id, exc_info=True)
|
||||||
|
|
||||||
|
|
||||||
def _render_captcha_image(code: str) -> str:
|
def _render_captcha_image(code: str) -> str:
|
||||||
image = Image.new("RGB", (CAPTCHA_WIDTH, CAPTCHA_HEIGHT), "#f7fbfa")
|
image = Image.new("RGB", (CAPTCHA_WIDTH, CAPTCHA_HEIGHT), "#f7fbfa")
|
||||||
draw = ImageDraw.Draw(image)
|
draw = ImageDraw.Draw(image)
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from functools import lru_cache
|
from functools import lru_cache
|
||||||
|
|
||||||
|
from redis import Redis as SyncRedis
|
||||||
from redis.asyncio import Redis
|
from redis.asyncio import Redis
|
||||||
|
|
||||||
from app.core.config import get_settings
|
from app.core.config import get_settings
|
||||||
@@ -15,6 +16,14 @@ def get_redis_client() -> Redis | None:
|
|||||||
return Redis.from_url(redis_url, decode_responses=True)
|
return Redis.from_url(redis_url, decode_responses=True)
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache(maxsize=1)
|
||||||
|
def get_sync_redis_client() -> SyncRedis | None:
|
||||||
|
redis_url = get_settings().redis_url.strip()
|
||||||
|
if not redis_url:
|
||||||
|
return None
|
||||||
|
return SyncRedis.from_url(redis_url, decode_responses=True)
|
||||||
|
|
||||||
|
|
||||||
async def ping_redis() -> bool:
|
async def ping_redis() -> bool:
|
||||||
client = get_redis_client()
|
client = get_redis_client()
|
||||||
if client is None:
|
if client is None:
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ from sqlalchemy.orm import Session
|
|||||||
|
|
||||||
from app.core.config import get_settings
|
from app.core.config import get_settings
|
||||||
from app.models.ai_config import SystemConfig
|
from app.models.ai_config import SystemConfig
|
||||||
|
from app.services.redis_client import get_sync_redis_client
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -46,16 +47,25 @@ class SmsCodeService:
|
|||||||
code = config.mock_sms_code if config.mock_sms_enabled else _generate_sms_code()
|
code = config.mock_sms_code if config.mock_sms_enabled else _generate_sms_code()
|
||||||
if not config.mock_sms_enabled:
|
if not config.mock_sms_enabled:
|
||||||
_send_aliyun_sms(phone, code, config)
|
_send_aliyun_sms(phone, code, config)
|
||||||
cls._codes[phone] = SmsCodeRecord(
|
ttl_seconds = config.sms_code_expire_minutes * 60
|
||||||
code=code,
|
if _set_redis_code(phone, code, ttl_seconds):
|
||||||
expires_at=datetime.now(UTC) + timedelta(minutes=config.sms_code_expire_minutes),
|
cls._codes.pop(phone, None)
|
||||||
)
|
return
|
||||||
|
cls._codes[phone] = SmsCodeRecord(code=code, expires_at=datetime.now(UTC) + timedelta(seconds=ttl_seconds))
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def verify_code(cls, db: Session, phone: str, code: str) -> None:
|
def verify_code(cls, db: Session, phone: str, code: str) -> None:
|
||||||
cls._validate_phone(phone)
|
cls._validate_phone(phone)
|
||||||
record = cls._codes.get(phone)
|
|
||||||
config = _load_sms_config(db)
|
config = _load_sms_config(db)
|
||||||
|
redis_available, redis_code = _get_redis_code(phone)
|
||||||
|
if redis_available and redis_code is not None:
|
||||||
|
if redis_code != code:
|
||||||
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="验证码错误")
|
||||||
|
_delete_redis_code(phone)
|
||||||
|
cls._codes.pop(phone, None)
|
||||||
|
return
|
||||||
|
|
||||||
|
record = cls._codes.get(phone)
|
||||||
if config.mock_sms_enabled and record is None and code == config.mock_sms_code:
|
if config.mock_sms_enabled and record is None and code == config.mock_sms_code:
|
||||||
return
|
return
|
||||||
if record is None:
|
if record is None:
|
||||||
@@ -177,6 +187,43 @@ def _generate_sms_code() -> str:
|
|||||||
return f"{random.SystemRandom().randint(0, 999999):06d}"
|
return f"{random.SystemRandom().randint(0, 999999):06d}"
|
||||||
|
|
||||||
|
|
||||||
|
def _sms_code_key(phone: str) -> str:
|
||||||
|
return f"auth:sms_code:{phone}"
|
||||||
|
|
||||||
|
|
||||||
|
def _set_redis_code(phone: str, code: str, ttl_seconds: int) -> bool:
|
||||||
|
client = get_sync_redis_client()
|
||||||
|
if client is None:
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
return bool(client.set(_sms_code_key(phone), code, ex=ttl_seconds))
|
||||||
|
except Exception:
|
||||||
|
logger.warning("Redis SMS code write failed for %s", _mask_phone(phone), exc_info=True)
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _get_redis_code(phone: str) -> tuple[bool, str | None]:
|
||||||
|
client = get_sync_redis_client()
|
||||||
|
if client is None:
|
||||||
|
return False, None
|
||||||
|
try:
|
||||||
|
value = client.get(_sms_code_key(phone))
|
||||||
|
except Exception:
|
||||||
|
logger.warning("Redis SMS code read failed for %s", _mask_phone(phone), exc_info=True)
|
||||||
|
return False, None
|
||||||
|
return True, value if isinstance(value, str) else None
|
||||||
|
|
||||||
|
|
||||||
|
def _delete_redis_code(phone: str) -> None:
|
||||||
|
client = get_sync_redis_client()
|
||||||
|
if client is None:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
client.delete(_sms_code_key(phone))
|
||||||
|
except Exception:
|
||||||
|
logger.warning("Redis SMS code delete failed for %s", _mask_phone(phone), exc_info=True)
|
||||||
|
|
||||||
|
|
||||||
def _config_text(values: dict[str, str], key: str, default: str) -> str:
|
def _config_text(values: dict[str, str], key: str, default: str) -> str:
|
||||||
value = values.get(key)
|
value = values.get(key)
|
||||||
return value.strip() if value is not None else default
|
return value.strip() if value is not None else default
|
||||||
|
|||||||
@@ -67,3 +67,26 @@
|
|||||||
- 手机号不存在时,发送短信验证码接口返回“手机号不在学员名单中,请联系管理员”,前端可见。
|
- 手机号不存在时,发送短信验证码接口返回“手机号不在学员名单中,请联系管理员”,前端可见。
|
||||||
- 图形验证码通过且手机号存在时,mock 短信仍可正常发送。
|
- 图形验证码通过且手机号存在时,mock 短信仍可正常发送。
|
||||||
- Docker 环境中图形验证码字体清晰,不能退化成过小或错位的默认字体。
|
- Docker 环境中图形验证码字体清晰,不能退化成过小或错位的默认字体。
|
||||||
|
|
||||||
|
## 2026-07-09 补充:验证码状态迁移到 Redis
|
||||||
|
|
||||||
|
### 变更原因
|
||||||
|
|
||||||
|
图形验证码和短信验证码最初沿用进程内字典保存状态。单 worker 本地环境可以正常使用,但生产环境如果开启多个 backend worker 或多实例部署,就可能出现验证码由 A worker 生成、校验请求打到 B worker 后找不到验证码的问题。
|
||||||
|
|
||||||
|
### 处理决策
|
||||||
|
|
||||||
|
1. 图形验证码状态优先写入 Redis,key 为 `auth:captcha:{captcha_id}`,TTL 为 300 秒。
|
||||||
|
2. 短信验证码状态优先写入 Redis,key 为 `auth:sms_code:{phone}`,TTL 使用系统配置中的短信验证码有效期。
|
||||||
|
3. Redis 可用时,所有 worker 共享同一份验证码状态。
|
||||||
|
4. Redis 未配置或临时不可用时,保留原进程内字典作为降级路径,避免本地开发环境完全不可用。
|
||||||
|
5. 验证成功后删除对应 Redis key,保持验证码一次性使用。
|
||||||
|
6. mock 短信开关开启时,仍保留“未发送但输入固定 mock 验证码可登录”的开发便利能力。
|
||||||
|
|
||||||
|
### 新增验收标准
|
||||||
|
|
||||||
|
- Docker dev 环境下,图形验证码和短信验证码应写入 Redis,并可通过 Redis key 观察到 TTL。
|
||||||
|
- 发送短信验证码成功后,Redis 中应存在 `auth:sms_code:{phone}`。
|
||||||
|
- 图形验证码生成后,Redis 中应存在 `auth:captcha:{captcha_id}`。
|
||||||
|
- 验证码校验成功后,对应 Redis key 应被删除。
|
||||||
|
- Redis 不可用时,单 worker 本地开发仍可使用内存降级路径。
|
||||||
|
|||||||
Reference in New Issue
Block a user