release(main): 同步 dev 最新候选改动
Storage Persistence Guard / storage-persistence-audit (push) Has been cancelled
Client Quality Gates / Shared client and Web (push) Has been cancelled
Client Quality Gates / macOS Desktop (push) Has been cancelled
Client Quality Gates / Shared client and Web (pull_request) Has been cancelled
Client Quality Gates / macOS Desktop (pull_request) Has been cancelled
Storage Persistence Guard / storage-persistence-audit (pull_request) Has been cancelled
Storage Persistence Guard / storage-persistence-audit (push) Has been cancelled
Client Quality Gates / Shared client and Web (push) Has been cancelled
Client Quality Gates / macOS Desktop (push) Has been cancelled
Client Quality Gates / Shared client and Web (pull_request) Has been cancelled
Client Quality Gates / macOS Desktop (pull_request) Has been cancelled
Storage Persistence Guard / storage-persistence-audit (pull_request) Has been cancelled
This commit is contained in:
@@ -0,0 +1,289 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import binascii
|
||||
import hashlib
|
||||
import hmac
|
||||
import uuid
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from anyio import to_thread
|
||||
from fastapi import HTTPException, status
|
||||
from jose import JWTError, jwt
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.security import hash_password, verify_password
|
||||
from app.models.collaboration import CollaborationFile, CollaborationShareLink
|
||||
from app.schemas.collaboration import (
|
||||
CollaborationPublicShareMetadata,
|
||||
CollaborationShareAccessGrant,
|
||||
CollaborationShareLinkRead,
|
||||
CollaborationShareLinkUpdate,
|
||||
)
|
||||
from app.services import collaboration_service
|
||||
|
||||
|
||||
SHARE_PATH = "/collaboration/share"
|
||||
SHARE_ACCESS_TTL_SECONDS = 30 * 60
|
||||
PASSWORD_FAILURE_WINDOW = timedelta(minutes=15)
|
||||
PASSWORD_LOCK_DURATION = timedelta(minutes=15)
|
||||
PASSWORD_FAILURE_LIMIT = 5
|
||||
_ACCESS_PURPOSE = "ctms-collaboration-share-access"
|
||||
_EXPIRY_DURATIONS = {
|
||||
"ONE_DAY": timedelta(days=1),
|
||||
"SEVEN_DAYS": timedelta(days=7),
|
||||
"THIRTY_DAYS": timedelta(days=30),
|
||||
"PERMANENT": None,
|
||||
}
|
||||
|
||||
|
||||
def _now() -> datetime:
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
def _signing_key() -> bytes:
|
||||
return hmac.new(
|
||||
settings.JWT_SECRET_KEY.encode("utf-8"),
|
||||
b"ctms-collaboration-share-v1",
|
||||
hashlib.sha256,
|
||||
).digest()
|
||||
|
||||
|
||||
def _b64encode(value: bytes) -> str:
|
||||
return base64.urlsafe_b64encode(value).rstrip(b"=").decode("ascii")
|
||||
|
||||
|
||||
def _b64decode(value: str) -> bytes:
|
||||
padding = "=" * (-len(value) % 4)
|
||||
return base64.urlsafe_b64decode(f"{value}{padding}".encode("ascii"))
|
||||
|
||||
|
||||
def share_token(link: CollaborationShareLink) -> str:
|
||||
payload = f"{link.id}.{link.token_version}".encode("ascii")
|
||||
signature = hmac.new(_signing_key(), payload, hashlib.sha256).digest()
|
||||
return f"{_b64encode(payload)}.{_b64encode(signature)}"
|
||||
|
||||
|
||||
def _decode_share_token(value: str | None) -> tuple[uuid.UUID, int]:
|
||||
token = (value or "").strip()
|
||||
if not token or len(token) > 256 or token.count(".") != 1:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="共享链接不存在或已失效")
|
||||
encoded_payload, encoded_signature = token.split(".", 1)
|
||||
try:
|
||||
payload = _b64decode(encoded_payload)
|
||||
actual_signature = _b64decode(encoded_signature)
|
||||
if (
|
||||
not hmac.compare_digest(_b64encode(payload), encoded_payload)
|
||||
or not hmac.compare_digest(_b64encode(actual_signature), encoded_signature)
|
||||
):
|
||||
raise ValueError("non-canonical token encoding")
|
||||
expected_signature = hmac.new(_signing_key(), payload, hashlib.sha256).digest()
|
||||
if not hmac.compare_digest(actual_signature, expected_signature):
|
||||
raise ValueError("signature mismatch")
|
||||
raw_id, raw_version = payload.decode("ascii").split(".", 1)
|
||||
return uuid.UUID(raw_id), int(raw_version)
|
||||
except (ValueError, UnicodeError, TypeError, binascii.Error) as exc:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="共享链接不存在或已失效") from exc
|
||||
|
||||
|
||||
def _expiry_for_policy(policy: str, now: datetime) -> datetime | None:
|
||||
duration = _EXPIRY_DURATIONS[policy]
|
||||
return now + duration if duration else None
|
||||
|
||||
|
||||
def _is_expired(link: CollaborationShareLink, now: datetime | None = None) -> bool:
|
||||
return bool(link.expires_at and link.expires_at <= (now or _now()))
|
||||
|
||||
|
||||
async def _link_for_file(
|
||||
db: AsyncSession,
|
||||
item: CollaborationFile,
|
||||
user,
|
||||
*,
|
||||
create: bool,
|
||||
lock: bool = False,
|
||||
) -> CollaborationShareLink | None:
|
||||
await collaboration_service.require_file_manager(db, item, user)
|
||||
statement = select(CollaborationShareLink).where(CollaborationShareLink.file_id == item.id)
|
||||
if lock:
|
||||
statement = statement.with_for_update()
|
||||
link = await db.scalar(statement)
|
||||
if link or not create:
|
||||
return link
|
||||
now = _now()
|
||||
link = CollaborationShareLink(
|
||||
file_id=item.id,
|
||||
enabled=False,
|
||||
access_mode="VIEW",
|
||||
expiry_policy="SEVEN_DAYS",
|
||||
expires_at=now + timedelta(days=7),
|
||||
created_by=user.id,
|
||||
updated_by=user.id,
|
||||
)
|
||||
db.add(link)
|
||||
await db.flush()
|
||||
return link
|
||||
|
||||
|
||||
def share_link_read(link: CollaborationShareLink) -> CollaborationShareLinkRead:
|
||||
return CollaborationShareLinkRead(
|
||||
id=link.id,
|
||||
file_id=link.file_id,
|
||||
enabled=link.enabled,
|
||||
access_mode=link.access_mode,
|
||||
expiry_policy=link.expiry_policy,
|
||||
expires_at=link.expires_at,
|
||||
has_password=bool(link.password_hash),
|
||||
share_path=SHARE_PATH,
|
||||
share_token=share_token(link) if link.enabled else None,
|
||||
created_at=link.created_at,
|
||||
updated_at=link.updated_at,
|
||||
)
|
||||
|
||||
|
||||
async def get_share_link(
|
||||
db: AsyncSession, item: CollaborationFile, user
|
||||
) -> CollaborationShareLinkRead:
|
||||
link = await _link_for_file(db, item, user, create=True)
|
||||
assert link is not None
|
||||
await db.commit()
|
||||
await db.refresh(link)
|
||||
return share_link_read(link)
|
||||
|
||||
|
||||
async def update_share_link(
|
||||
db: AsyncSession,
|
||||
item: CollaborationFile,
|
||||
payload: CollaborationShareLinkUpdate,
|
||||
user,
|
||||
) -> CollaborationShareLinkRead:
|
||||
link = await _link_for_file(db, item, user, create=True, lock=True)
|
||||
assert link is not None
|
||||
now = _now()
|
||||
link.enabled = payload.enabled
|
||||
link.access_mode = payload.access_mode
|
||||
link.expiry_policy = payload.expiry_policy
|
||||
link.expires_at = _expiry_for_policy(payload.expiry_policy, now)
|
||||
link.updated_by = user.id
|
||||
if payload.password_mode == "SET":
|
||||
link.password_hash = await to_thread.run_sync(hash_password, payload.password or "")
|
||||
link.failed_attempts = 0
|
||||
link.last_failed_at = None
|
||||
link.locked_until = None
|
||||
elif payload.password_mode == "CLEAR":
|
||||
link.password_hash = None
|
||||
link.failed_attempts = 0
|
||||
link.last_failed_at = None
|
||||
link.locked_until = None
|
||||
await db.commit()
|
||||
await db.refresh(link)
|
||||
return share_link_read(link)
|
||||
|
||||
|
||||
async def resolve_active_share(
|
||||
db: AsyncSession,
|
||||
token: str | None,
|
||||
*,
|
||||
lock: bool = False,
|
||||
) -> tuple[CollaborationShareLink, CollaborationFile]:
|
||||
link_id, version = _decode_share_token(token)
|
||||
statement = select(CollaborationShareLink).where(CollaborationShareLink.id == link_id)
|
||||
if lock:
|
||||
statement = statement.with_for_update()
|
||||
link = await db.scalar(statement)
|
||||
if not link or link.token_version != version or not link.enabled:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="共享链接不存在或已失效")
|
||||
if _is_expired(link):
|
||||
raise HTTPException(status_code=status.HTTP_410_GONE, detail="共享链接已过期")
|
||||
item = await db.get(CollaborationFile, link.file_id)
|
||||
if not item or item.deleted_at or item.status != "ACTIVE":
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="共享文件不存在或已停止共享")
|
||||
return link, item
|
||||
|
||||
|
||||
async def public_metadata(
|
||||
db: AsyncSession, token: str | None
|
||||
) -> CollaborationPublicShareMetadata:
|
||||
link, item = await resolve_active_share(db, token)
|
||||
return CollaborationPublicShareMetadata(
|
||||
file_name=item.title,
|
||||
file_type=item.file_type,
|
||||
access_mode="edit" if link.access_mode == "EDIT" else "view",
|
||||
allow_export=item.allow_export,
|
||||
requires_password=bool(link.password_hash),
|
||||
expires_at=link.expires_at,
|
||||
)
|
||||
|
||||
|
||||
def _grant_token(link: CollaborationShareLink) -> CollaborationShareAccessGrant:
|
||||
now = _now()
|
||||
expires_at = now + timedelta(seconds=SHARE_ACCESS_TTL_SECONDS)
|
||||
if link.expires_at and link.expires_at < expires_at:
|
||||
expires_at = link.expires_at
|
||||
value = jwt.encode(
|
||||
{
|
||||
"purpose": _ACCESS_PURPOSE,
|
||||
"sub": str(link.id),
|
||||
"ver": link.token_version,
|
||||
"iat": int(now.timestamp()),
|
||||
"exp": int(expires_at.timestamp()),
|
||||
},
|
||||
_signing_key().hex(),
|
||||
algorithm="HS256",
|
||||
)
|
||||
return CollaborationShareAccessGrant(access_token=value, expires_at=expires_at)
|
||||
|
||||
|
||||
async def verify_share_password(
|
||||
db: AsyncSession,
|
||||
token: str | None,
|
||||
password: str,
|
||||
) -> CollaborationShareAccessGrant:
|
||||
link, _ = await resolve_active_share(db, token, lock=True)
|
||||
if not link.password_hash:
|
||||
return _grant_token(link)
|
||||
now = _now()
|
||||
if link.locked_until and link.locked_until > now:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||
detail="密码尝试次数过多,请稍后再试",
|
||||
)
|
||||
if link.last_failed_at and now - link.last_failed_at > PASSWORD_FAILURE_WINDOW:
|
||||
link.failed_attempts = 0
|
||||
valid = await to_thread.run_sync(verify_password, password, link.password_hash)
|
||||
if not valid:
|
||||
link.failed_attempts += 1
|
||||
link.last_failed_at = now
|
||||
if link.failed_attempts >= PASSWORD_FAILURE_LIMIT:
|
||||
link.locked_until = now + PASSWORD_LOCK_DURATION
|
||||
await db.commit()
|
||||
if link.locked_until:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||
detail="密码尝试次数过多,请稍后再试",
|
||||
)
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="链接密码不正确")
|
||||
link.failed_attempts = 0
|
||||
link.last_failed_at = None
|
||||
link.locked_until = None
|
||||
await db.commit()
|
||||
return _grant_token(link)
|
||||
|
||||
|
||||
def validate_access_grant(link: CollaborationShareLink, value: str | None) -> None:
|
||||
if not link.password_hash:
|
||||
return
|
||||
if not value:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="请输入链接密码")
|
||||
try:
|
||||
payload = jwt.decode(value, _signing_key().hex(), algorithms=["HS256"])
|
||||
if (
|
||||
payload.get("purpose") != _ACCESS_PURPOSE
|
||||
or not hmac.compare_digest(str(payload.get("sub") or ""), str(link.id))
|
||||
or payload.get("ver") != link.token_version
|
||||
):
|
||||
raise JWTError("share grant mismatch")
|
||||
except JWTError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="链接访问凭证已失效") from exc
|
||||
Reference in New Issue
Block a user