feat: 支持按需触发短信图形验证
This commit is contained in:
@@ -9,8 +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)
|
||||
captchaId: str | None = Field(default=None, min_length=1, max_length=100)
|
||||
captchaCode: str | None = Field(default=None, min_length=4, max_length=8)
|
||||
|
||||
|
||||
class LoginRequest(BaseModel):
|
||||
|
||||
@@ -14,11 +14,29 @@ from app.services.security_state_service import SecurityStateService
|
||||
|
||||
|
||||
class AuthService:
|
||||
CAPTCHA_REQUIRED_SECONDS = 60 * 60
|
||||
|
||||
@classmethod
|
||||
def send_sms_code(cls, db: Session, phone: str, captcha_id: str, captcha_code: str, ip: str) -> None:
|
||||
CaptchaService.verify(captcha_id, captcha_code)
|
||||
def send_sms_code(
|
||||
cls,
|
||||
db: Session,
|
||||
phone: str,
|
||||
captcha_id: str | None,
|
||||
captcha_code: str | None,
|
||||
ip: str,
|
||||
) -> None:
|
||||
user = cls._get_existing_user(db, phone)
|
||||
cls._ensure_user_can_login(user)
|
||||
captcha_key = cls._captcha_required_key(phone)
|
||||
has_captcha = bool(captcha_id or captcha_code)
|
||||
if has_captcha:
|
||||
if not captcha_id or not captcha_code:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="请完整填写图形验证码")
|
||||
CaptchaService.verify(captcha_id, captcha_code)
|
||||
SecurityStateService.clear(captcha_key)
|
||||
elif SecurityStateService.has_flag(captcha_key):
|
||||
raise HTTPException(status_code=428, detail="需要完成图形验证码后再发送短信")
|
||||
|
||||
SecurityStateService.enforce_limit(
|
||||
f"rate:sms:ip:{ip}", limit=20, window_seconds=3600, message="当前网络发送验证码过于频繁,请稍后再试"
|
||||
)
|
||||
@@ -26,6 +44,7 @@ class AuthService:
|
||||
f"rate:sms:phone:{phone}", limit=1, window_seconds=60, message="验证码发送过于频繁,请一分钟后再试"
|
||||
)
|
||||
SmsCodeService.send_code(db, phone)
|
||||
SecurityStateService.mark_flag(captcha_key, cls.CAPTCHA_REQUIRED_SECONDS)
|
||||
|
||||
@classmethod
|
||||
def login_with_sms(cls, db: Session, phone: str, code: str) -> dict:
|
||||
@@ -36,6 +55,7 @@ class AuthService:
|
||||
try:
|
||||
SmsCodeService.verify_code(db, phone, code)
|
||||
except HTTPException:
|
||||
SecurityStateService.mark_flag(cls._captcha_required_key(phone), cls.CAPTCHA_REQUIRED_SECONDS)
|
||||
SecurityStateService.record_failure(
|
||||
failure_key,
|
||||
limit=5,
|
||||
@@ -84,3 +104,7 @@ class AuthService:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="账号尚未生效")
|
||||
if user.expired_at and user.expired_at < now:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="账号已过期")
|
||||
|
||||
@staticmethod
|
||||
def _captcha_required_key(phone: str) -> str:
|
||||
return f"auth:sms:captcha_required:{phone}"
|
||||
|
||||
@@ -48,6 +48,22 @@ class SecurityStateService:
|
||||
with cls._lock:
|
||||
cls._counters.pop(key, None)
|
||||
|
||||
@classmethod
|
||||
def mark_flag(cls, key: str, ttl_seconds: int) -> None:
|
||||
client = get_sync_redis_client()
|
||||
if client is not None:
|
||||
try:
|
||||
client.set(key, "1", ex=ttl_seconds)
|
||||
return
|
||||
except Exception:
|
||||
pass
|
||||
with cls._lock:
|
||||
cls._counters[key] = _Counter(1, time.monotonic() + ttl_seconds)
|
||||
|
||||
@classmethod
|
||||
def has_flag(cls, key: str) -> bool:
|
||||
return cls._get(key) > 0
|
||||
|
||||
@classmethod
|
||||
def revoke_token(cls, jti: str, expires_at: int | float | None) -> None:
|
||||
now = datetime.now(UTC)
|
||||
|
||||
Reference in New Issue
Block a user