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,233 @@
|
||||
"""Server-authoritative login session activity for the admin account list."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any, Iterable
|
||||
|
||||
from sqlalchemy import select, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.request_context import resolve_client_ip
|
||||
from app.models.user_login_session import UserLoginSession
|
||||
from app.services.ip_location import resolve_ip_location
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class UserLoginSummary:
|
||||
status: str = "OFFLINE"
|
||||
last_login_at: datetime | None = None
|
||||
last_seen_at: datetime | None = None
|
||||
client_type: str | None = None
|
||||
active_session_count: int = 0
|
||||
|
||||
|
||||
def _now() -> datetime:
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
def _as_utc(value: datetime) -> datetime:
|
||||
return value.replace(tzinfo=timezone.utc) if value.tzinfo is None else value.astimezone(timezone.utc)
|
||||
|
||||
|
||||
def _text_header(headers: Any, name: str, limit: int) -> str | None:
|
||||
value = (headers.get(name) or "").strip()
|
||||
return value[:limit] or None
|
||||
|
||||
|
||||
def session_id_from_payload(payload: dict[str, Any]) -> uuid.UUID:
|
||||
raw_session_id = payload.get("sid")
|
||||
try:
|
||||
return uuid.UUID(str(raw_session_id))
|
||||
except (TypeError, ValueError):
|
||||
# Tokens issued before session tracking are mapped to a deterministic
|
||||
# legacy session without persisting the token or a token-derived value.
|
||||
legacy_key = ":".join(
|
||||
[
|
||||
str(payload.get("sub") or ""),
|
||||
str(payload.get("orig_iat") or payload.get("iat") or ""),
|
||||
str(payload.get("client_type") or "web"),
|
||||
]
|
||||
)
|
||||
return uuid.uuid5(uuid.NAMESPACE_URL, f"ctms:legacy-session:{legacy_key}")
|
||||
|
||||
|
||||
def session_client_type(payload: dict[str, Any], headers: Any) -> str:
|
||||
from_payload = (payload.get("client_type") or "").strip().lower()
|
||||
from_header = (headers.get("x-ctms-client-type") or "").strip().lower()
|
||||
return "desktop" if "desktop" in {from_payload, from_header} else "web"
|
||||
|
||||
|
||||
def login_activity_status(session: UserLoginSession, *, reference_time: datetime | None = None) -> str:
|
||||
if session.ended_at is not None:
|
||||
return "ENDED"
|
||||
cutoff = (reference_time or _now()) - timedelta(seconds=settings.USER_SESSION_ONLINE_SECONDS)
|
||||
return "ONLINE" if _as_utc(session.last_seen_at) >= cutoff else "OFFLINE"
|
||||
|
||||
|
||||
def login_activity_payload(
|
||||
session: UserLoginSession,
|
||||
*,
|
||||
reference_time: datetime | None = None,
|
||||
) -> dict[str, Any]:
|
||||
ip_location = resolve_ip_location(session.login_ip) if session.login_ip else None
|
||||
return {
|
||||
"id": session.id,
|
||||
"client_type": session.client_type,
|
||||
"client_platform": session.client_platform,
|
||||
"client_version": session.client_version,
|
||||
"client_source": session.client_source,
|
||||
"login_ip": session.login_ip,
|
||||
"ip_location": ip_location.location if ip_location else None,
|
||||
"login_at": session.login_at,
|
||||
"last_seen_at": session.last_seen_at,
|
||||
"ended_at": session.ended_at,
|
||||
"end_reason": session.end_reason,
|
||||
"activity_status": login_activity_status(session, reference_time=reference_time),
|
||||
}
|
||||
|
||||
|
||||
async def create_login_session(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
session_id: uuid.UUID,
|
||||
user_id: uuid.UUID,
|
||||
request: Any,
|
||||
login_at: datetime | None = None,
|
||||
) -> UserLoginSession:
|
||||
occurred_at = login_at or _now()
|
||||
session = UserLoginSession(
|
||||
id=session_id,
|
||||
user_id=user_id,
|
||||
client_type=session_client_type({}, request.headers),
|
||||
client_platform=_text_header(request.headers, "x-ctms-client-platform", 32),
|
||||
client_version=_text_header(request.headers, "x-ctms-client-version", 64),
|
||||
client_source=_text_header(request.headers, "x-ctms-client-source", 32),
|
||||
login_ip=resolve_client_ip(request),
|
||||
login_at=occurred_at,
|
||||
last_seen_at=occurred_at,
|
||||
)
|
||||
db.add(session)
|
||||
await db.commit()
|
||||
await db.refresh(session)
|
||||
return session
|
||||
|
||||
|
||||
async def touch_login_session(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
user_id: uuid.UUID,
|
||||
payload: dict[str, Any],
|
||||
request: Any,
|
||||
) -> UserLoginSession | None:
|
||||
session_id = session_id_from_payload(payload)
|
||||
session = await db.get(UserLoginSession, session_id)
|
||||
now = _now()
|
||||
if session is None:
|
||||
session = UserLoginSession(
|
||||
id=session_id,
|
||||
user_id=user_id,
|
||||
client_type=session_client_type(payload, request.headers),
|
||||
client_platform=_text_header(request.headers, "x-ctms-client-platform", 32),
|
||||
client_version=_text_header(request.headers, "x-ctms-client-version", 64),
|
||||
client_source=_text_header(request.headers, "x-ctms-client-source", 32),
|
||||
login_ip=resolve_client_ip(request),
|
||||
login_at=now,
|
||||
last_seen_at=now,
|
||||
)
|
||||
db.add(session)
|
||||
elif session.user_id != user_id or session.ended_at is not None:
|
||||
return None
|
||||
else:
|
||||
session.last_seen_at = now
|
||||
session.client_platform = _text_header(request.headers, "x-ctms-client-platform", 32) or session.client_platform
|
||||
session.client_version = _text_header(request.headers, "x-ctms-client-version", 64) or session.client_version
|
||||
await db.commit()
|
||||
await db.refresh(session)
|
||||
return session
|
||||
|
||||
|
||||
async def end_login_session(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
user_id: uuid.UUID,
|
||||
payload: dict[str, Any],
|
||||
reason: str = "logout",
|
||||
) -> bool:
|
||||
session_id = session_id_from_payload(payload)
|
||||
result = await db.execute(
|
||||
update(UserLoginSession)
|
||||
.where(
|
||||
UserLoginSession.id == session_id,
|
||||
UserLoginSession.user_id == user_id,
|
||||
UserLoginSession.ended_at.is_(None),
|
||||
)
|
||||
.values(ended_at=_now(), end_reason=reason, last_seen_at=_now())
|
||||
)
|
||||
await db.commit()
|
||||
return bool(result.rowcount)
|
||||
|
||||
|
||||
async def get_login_summaries(
|
||||
db: AsyncSession,
|
||||
user_ids: Iterable[uuid.UUID],
|
||||
) -> dict[uuid.UUID, UserLoginSummary]:
|
||||
ids = list(user_ids)
|
||||
if not ids:
|
||||
return {}
|
||||
rows = (
|
||||
await db.execute(
|
||||
select(UserLoginSession)
|
||||
.where(UserLoginSession.user_id.in_(ids))
|
||||
.order_by(UserLoginSession.user_id, UserLoginSession.login_at.desc())
|
||||
)
|
||||
).scalars().all()
|
||||
cutoff = _now() - timedelta(seconds=settings.USER_SESSION_ONLINE_SECONDS)
|
||||
summaries: dict[uuid.UUID, UserLoginSummary] = {}
|
||||
mutable: dict[uuid.UUID, dict[str, Any]] = {}
|
||||
for row in rows:
|
||||
current = mutable.setdefault(
|
||||
row.user_id,
|
||||
{
|
||||
"last_login_at": row.login_at,
|
||||
"last_seen_at": row.last_seen_at,
|
||||
"client_type": row.client_type,
|
||||
"active_sources": set(),
|
||||
},
|
||||
)
|
||||
row_last_seen_at = _as_utc(row.last_seen_at)
|
||||
if row_last_seen_at and (
|
||||
current["last_seen_at"] is None or row_last_seen_at > _as_utc(current["last_seen_at"])
|
||||
):
|
||||
current["last_seen_at"] = row_last_seen_at
|
||||
if row.ended_at is None and row_last_seen_at >= cutoff:
|
||||
source_key = (row.client_type, row.login_ip) if row.login_ip else ("session", row.id)
|
||||
current["active_sources"].add(source_key)
|
||||
for user_id, item in mutable.items():
|
||||
active_session_count = len(item["active_sources"])
|
||||
summaries[user_id] = UserLoginSummary(
|
||||
status="ONLINE" if active_session_count else "OFFLINE",
|
||||
last_login_at=item["last_login_at"],
|
||||
last_seen_at=item["last_seen_at"],
|
||||
client_type=item["client_type"],
|
||||
active_session_count=active_session_count,
|
||||
)
|
||||
return summaries
|
||||
|
||||
|
||||
async def list_login_activities(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
user_id: uuid.UUID,
|
||||
limit: int,
|
||||
) -> list[UserLoginSession]:
|
||||
result = await db.execute(
|
||||
select(UserLoginSession)
|
||||
.where(UserLoginSession.user_id == user_id)
|
||||
.order_by(UserLoginSession.login_at.desc())
|
||||
.limit(limit)
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
Reference in New Issue
Block a user