Store login verification codes in Redis

This commit is contained in:
2026-07-09 12:16:21 +08:00
parent c56a4b49e1
commit cde7da572b
4 changed files with 138 additions and 9 deletions

View File

@@ -12,6 +12,8 @@ from uuid import uuid4
from fastapi import HTTPException, status
from PIL import Image, ImageDraw, ImageFilter, ImageFont
from app.services.redis_client import get_sync_redis_client
logger = logging.getLogger(__name__)
CAPTCHA_EXPIRES_SECONDS = 300
@@ -34,10 +36,13 @@ class CaptchaService:
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),
)
if _set_redis_captcha(captcha_id, code):
cls._records.pop(captcha_id, None)
else:
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),
@@ -46,6 +51,14 @@ class CaptchaService:
@classmethod
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)
if record is None:
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))
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:
image = Image.new("RGB", (CAPTCHA_WIDTH, CAPTCHA_HEIGHT), "#f7fbfa")
draw = ImageDraw.Draw(image)

View File

@@ -2,6 +2,7 @@ from __future__ import annotations
from functools import lru_cache
from redis import Redis as SyncRedis
from redis.asyncio import Redis
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)
@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:
client = get_redis_client()
if client is None:

View File

@@ -13,6 +13,7 @@ from sqlalchemy.orm import Session
from app.core.config import get_settings
from app.models.ai_config import SystemConfig
from app.services.redis_client import get_sync_redis_client
logger = logging.getLogger(__name__)
@@ -46,16 +47,25 @@ class SmsCodeService:
code = config.mock_sms_code if config.mock_sms_enabled else _generate_sms_code()
if not config.mock_sms_enabled:
_send_aliyun_sms(phone, code, config)
cls._codes[phone] = SmsCodeRecord(
code=code,
expires_at=datetime.now(UTC) + timedelta(minutes=config.sms_code_expire_minutes),
)
ttl_seconds = config.sms_code_expire_minutes * 60
if _set_redis_code(phone, code, ttl_seconds):
cls._codes.pop(phone, None)
return
cls._codes[phone] = SmsCodeRecord(code=code, expires_at=datetime.now(UTC) + timedelta(seconds=ttl_seconds))
@classmethod
def verify_code(cls, db: Session, phone: str, code: str) -> None:
cls._validate_phone(phone)
record = cls._codes.get(phone)
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:
return
if record is None:
@@ -177,6 +187,43 @@ def _generate_sms_code() -> str:
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:
value = values.get(key)
return value.strip() if value is not None else default

View File

@@ -67,3 +67,26 @@
- 手机号不存在时,发送短信验证码接口返回“手机号不在学员名单中,请联系管理员”,前端可见。
- 图形验证码通过且手机号存在时mock 短信仍可正常发送。
- Docker 环境中图形验证码字体清晰,不能退化成过小或错位的默认字体。
## 2026-07-09 补充:验证码状态迁移到 Redis
### 变更原因
图形验证码和短信验证码最初沿用进程内字典保存状态。单 worker 本地环境可以正常使用,但生产环境如果开启多个 backend worker 或多实例部署,就可能出现验证码由 A worker 生成、校验请求打到 B worker 后找不到验证码的问题。
### 处理决策
1. 图形验证码状态优先写入 Rediskey 为 `auth:captcha:{captcha_id}`TTL 为 300 秒。
2. 短信验证码状态优先写入 Rediskey 为 `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 本地开发仍可使用内存降级路径。