- 新增后台登录图形验证码功能,完善登录安全防护 - 新增登录rememberMe参数,控制Refresh Token的会话持久化策略 - 实现OSS私有桶媒体URL自动签名,统一处理图片资源的临时访问签名 - 新增素材库数据库表与上传API,规范媒体资源管理流程 - 统一前端UI图标使用@element-plus/icons-vue,重构布局图标组件 - 登录页新增验证码输入、刷新功能,添加账号记忆与记住密码逻辑 - 更新全套文档,补充API契约、技术决策记录与集成流程说明 - 修复多个业务页面的图标展示问题,新增认证流程相关测试用例
91 lines
2.6 KiB
Python
91 lines
2.6 KiB
Python
import base64
|
|
import hashlib
|
|
import hmac
|
|
from time import time
|
|
from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
|
|
|
|
from .config import get_settings
|
|
|
|
|
|
SIGNED_QUERY_KEYS = {"OSSAccessKeyId", "Expires", "Signature"}
|
|
MEDIA_URL_EXPIRES_SECONDS = 3600
|
|
|
|
|
|
def normalized_oss_host(endpoint: str, bucket: str) -> tuple[str, str]:
|
|
raw = endpoint.strip().rstrip("/")
|
|
if "://" not in raw:
|
|
raw = f"https://{raw}"
|
|
parsed = urlsplit(raw)
|
|
if parsed.scheme not in {"http", "https"} or not parsed.netloc:
|
|
raise ValueError("invalid OSS endpoint")
|
|
host = parsed.netloc
|
|
if not host.lower().startswith(f"{bucket.lower()}."):
|
|
host = f"{bucket}.{host}"
|
|
return parsed.scheme, host
|
|
|
|
|
|
def sign_oss_get_url(
|
|
url: str,
|
|
*,
|
|
access_key_id: str,
|
|
access_key_secret: str,
|
|
endpoint: str,
|
|
bucket: str,
|
|
expires_at: int,
|
|
) -> str:
|
|
scheme, expected_host = normalized_oss_host(endpoint, bucket)
|
|
parsed = urlsplit(url)
|
|
if parsed.scheme != scheme or parsed.netloc.lower() != expected_host.lower():
|
|
return url
|
|
|
|
path = parsed.path or "/"
|
|
canonical_resource = f"/{bucket}{path}"
|
|
string_to_sign = f"GET\n\n\n{expires_at}\n{canonical_resource}"
|
|
signature = base64.b64encode(
|
|
hmac.new(
|
|
access_key_secret.strip().encode("utf-8"),
|
|
string_to_sign.encode("utf-8"),
|
|
hashlib.sha1,
|
|
).digest()
|
|
).decode("ascii")
|
|
query = [
|
|
(key, value)
|
|
for key, value in parse_qsl(parsed.query, keep_blank_values=True)
|
|
if key not in SIGNED_QUERY_KEYS
|
|
]
|
|
query.extend(
|
|
[
|
|
("OSSAccessKeyId", access_key_id.strip()),
|
|
("Expires", str(expires_at)),
|
|
("Signature", signature),
|
|
]
|
|
)
|
|
return urlunsplit((scheme, expected_host, path, urlencode(query), ""))
|
|
|
|
|
|
def resolve_media_url(url: str | None) -> str | None:
|
|
if not url or not isinstance(url, str):
|
|
return url
|
|
|
|
settings = get_settings()
|
|
values = (
|
|
settings.oss_access_key_id,
|
|
settings.oss_access_key_secret,
|
|
settings.oss_endpoint,
|
|
settings.oss_bucket_name,
|
|
)
|
|
if not all(value and value.strip() for value in values):
|
|
return url
|
|
|
|
try:
|
|
return sign_oss_get_url(
|
|
url,
|
|
access_key_id=settings.oss_access_key_id,
|
|
access_key_secret=settings.oss_access_key_secret,
|
|
endpoint=settings.oss_endpoint,
|
|
bucket=settings.oss_bucket_name,
|
|
expires_at=int(time()) + MEDIA_URL_EXPIRES_SECONDS,
|
|
)
|
|
except (TypeError, ValueError):
|
|
return url
|