feat: 添加后台登录验证码、记住密码功能,优化媒体资源与前端规范
- 新增后台登录图形验证码功能,完善登录安全防护 - 新增登录rememberMe参数,控制Refresh Token的会话持久化策略 - 实现OSS私有桶媒体URL自动签名,统一处理图片资源的临时访问签名 - 新增素材库数据库表与上传API,规范媒体资源管理流程 - 统一前端UI图标使用@element-plus/icons-vue,重构布局图标组件 - 登录页新增验证码输入、刷新功能,添加账号记忆与记住密码逻辑 - 更新全套文档,补充API契约、技术决策记录与集成流程说明 - 修复多个业务页面的图标展示问题,新增认证流程相关测试用例
This commit is contained in:
1 parent
2c8c327de7
commit
6245159e7c
35 files changed
+914
-79
No files matched your search
@@ -1,6 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import secrets
|
||||
from dataclasses import dataclass
|
||||
@@ -22,6 +23,11 @@ def new_refresh_token() -> str:
|
||||
return secrets.token_urlsafe(48)
|
||||
|
||||
|
||||
def hash_captcha_answer(captcha_id: str, answer: str) -> str:
|
||||
normalized = answer.strip().upper()
|
||||
return hashlib.sha256(f"{captcha_id}:{normalized}".encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SessionRecord:
|
||||
session_id: str
|
||||
@@ -30,6 +36,7 @@ class SessionRecord:
|
||||
refresh_hash: str
|
||||
access_expires_at: datetime
|
||||
refresh_expires_at: datetime
|
||||
remember_me: bool = False
|
||||
|
||||
|
||||
class AdminSessionStore(Protocol):
|
||||
@@ -69,6 +76,10 @@ class AdminSessionStore(Protocol):
|
||||
|
||||
def allow_login_attempt(self, identity: str, limit: int, window_seconds: int) -> bool: ...
|
||||
|
||||
def create_captcha(self, captcha_id: str, answer: str, ttl_seconds: int) -> None: ...
|
||||
|
||||
def consume_captcha(self, captcha_id: str, answer: str) -> bool: ...
|
||||
|
||||
|
||||
def _now() -> datetime:
|
||||
return datetime.now(timezone.utc)
|
||||
@@ -80,6 +91,7 @@ class InMemoryAdminSessionStore:
|
||||
self._refresh_index: dict[str, str] = {}
|
||||
self._permission_cache: dict[str, tuple[dict, datetime]] = {}
|
||||
self._login_attempts: dict[str, tuple[int, datetime]] = {}
|
||||
self._captchas: dict[str, tuple[str, datetime]] = {}
|
||||
|
||||
def create(self, **kwargs) -> None:
|
||||
record = SessionRecord(**kwargs)
|
||||
@@ -118,6 +130,7 @@ class InMemoryAdminSessionStore:
|
||||
refresh_hash=refresh_hash_next,
|
||||
access_expires_at=access_expires_at,
|
||||
refresh_expires_at=refresh_expires_at,
|
||||
remember_me=record.remember_me,
|
||||
)
|
||||
self._sessions[session_id] = next_record
|
||||
self._refresh_index[refresh_hash_next] = session_id
|
||||
@@ -157,6 +170,15 @@ class InMemoryAdminSessionStore:
|
||||
self._login_attempts[identity] = (attempts, expires_at)
|
||||
return attempts <= limit
|
||||
|
||||
def create_captcha(self, captcha_id: str, answer: str, ttl_seconds: int) -> None:
|
||||
self._captchas[captcha_id] = (hash_captcha_answer(captcha_id, answer), _now() + timedelta(seconds=max(1, ttl_seconds)))
|
||||
|
||||
def consume_captcha(self, captcha_id: str, answer: str) -> bool:
|
||||
record = self._captchas.pop(captcha_id, None)
|
||||
if not record or record[1] <= _now():
|
||||
return False
|
||||
return hmac.compare_digest(record[0], hash_captcha_answer(captcha_id, answer))
|
||||
|
||||
|
||||
class RedisAdminSessionStore:
|
||||
prefix = "wonderq:admin"
|
||||
@@ -183,6 +205,9 @@ class RedisAdminSessionStore:
|
||||
def _login_limit_key(self, identity: str) -> str:
|
||||
return f"{self.prefix}:login-limit:{hashlib.sha256(identity.encode('utf-8')).hexdigest()}"
|
||||
|
||||
def _captcha_key(self, captcha_id: str) -> str:
|
||||
return f"{self.prefix}:captcha:{captcha_id}"
|
||||
|
||||
@staticmethod
|
||||
def _serialize(record: SessionRecord) -> str:
|
||||
return json.dumps(
|
||||
@@ -193,6 +218,7 @@ class RedisAdminSessionStore:
|
||||
"refreshHash": record.refresh_hash,
|
||||
"accessExpiresAt": record.access_expires_at.isoformat(),
|
||||
"refreshExpiresAt": record.refresh_expires_at.isoformat(),
|
||||
"rememberMe": record.remember_me,
|
||||
}
|
||||
)
|
||||
|
||||
@@ -209,6 +235,7 @@ class RedisAdminSessionStore:
|
||||
refresh_hash=payload["refreshHash"],
|
||||
access_expires_at=datetime.fromisoformat(payload["accessExpiresAt"]),
|
||||
refresh_expires_at=datetime.fromisoformat(payload["refreshExpiresAt"]),
|
||||
remember_me=bool(payload.get("rememberMe", False)),
|
||||
)
|
||||
except (KeyError, TypeError, ValueError, json.JSONDecodeError) as exc:
|
||||
raise RedisUnavailableError("Redis 会话数据无效") from exc
|
||||
@@ -273,6 +300,7 @@ class RedisAdminSessionStore:
|
||||
refresh_hash=refresh_hash_next,
|
||||
access_expires_at=access_expires_at,
|
||||
refresh_expires_at=refresh_expires_at,
|
||||
remember_me=record.remember_me,
|
||||
)
|
||||
pipe.multi()
|
||||
pipe.delete(refresh_key)
|
||||
@@ -337,6 +365,28 @@ class RedisAdminSessionStore:
|
||||
except Exception as exc:
|
||||
raise RedisUnavailableError("Redis 登录限流不可用") from exc
|
||||
|
||||
def create_captcha(self, captcha_id: str, answer: str, ttl_seconds: int) -> None:
|
||||
self._ensure_available()
|
||||
try:
|
||||
self.client.set(self._captcha_key(captcha_id), hash_captcha_answer(captcha_id, answer), ex=max(1, ttl_seconds))
|
||||
except Exception as exc:
|
||||
raise RedisUnavailableError("Redis 验证码写入失败") from exc
|
||||
|
||||
def consume_captcha(self, captcha_id: str, answer: str) -> bool:
|
||||
self._ensure_available()
|
||||
script = """
|
||||
local value = redis.call('GET', KEYS[1])
|
||||
if value then redis.call('DEL', KEYS[1]) end
|
||||
return value
|
||||
"""
|
||||
try:
|
||||
stored = self.client.eval(script, 1, self._captcha_key(captcha_id))
|
||||
except Exception as exc:
|
||||
raise RedisUnavailableError("Redis 验证码校验不可用") from exc
|
||||
if not stored:
|
||||
return False
|
||||
return hmac.compare_digest(str(stored), hash_captcha_answer(captcha_id, answer))
|
||||
|
||||
|
||||
def get_admin_session_store() -> AdminSessionStore:
|
||||
return RedisAdminSessionStore()
|
||||
Reference in new issue
Block a user