ab59476d10
- 优化桌面标签、上下文标题、前进后退、导航栏隐藏和原生菜单体验 - 补充登录会话 IP 采集、地理位置回退、管理端展示及数据库迁移 - 更新桌面发布检查、运维文档和前后端测试覆盖
232 lines
7.9 KiB
Python
232 lines
7.9 KiB
Python
"""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_session_count": 0,
|
|
},
|
|
)
|
|
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:
|
|
current["active_session_count"] += 1
|
|
for user_id, item in mutable.items():
|
|
summaries[user_id] = UserLoginSummary(
|
|
status="ONLINE" if item["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=item["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())
|