feat(桌面与监控): 完善工作台导航和登录活动定位
- 优化桌面标签、上下文标题、前进后退、导航栏隐藏和原生菜单体验 - 补充登录会话 IP 采集、地理位置回退、管理端展示及数据库迁移 - 更新桌面发布检查、运维文档和前后端测试覆盖
This commit is contained in:
@@ -0,0 +1,25 @@
|
||||
"""Add the server-observed login IP to user login sessions.
|
||||
|
||||
Revision ID: 20260713_01
|
||||
Revises: 20260710_04
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
|
||||
revision = "20260713_01"
|
||||
down_revision = "20260710_04"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"user_login_sessions",
|
||||
sa.Column("login_ip", sa.String(length=45), nullable=True),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("user_login_sessions", "login_ip")
|
||||
@@ -28,6 +28,7 @@ from app.models.permission_metric_snapshot import PermissionMetricSnapshot
|
||||
from app.models.security_access_log import SecurityAccessLog
|
||||
from app.models.user import User
|
||||
from app.services.geo_location_metadata import GeoLocationMetadata, resolve_geo_location_metadata
|
||||
from app.services.ip_geolocation_fallback import resolve_external_ip_locations
|
||||
from app.services.ip_location import IpLocation, resolve_ip_location
|
||||
from app.services.monitoring_retention import get_monitoring_retention_status
|
||||
from app.services.permission_log_writer import get_log_writer
|
||||
@@ -1604,12 +1605,15 @@ async def get_ip_locations(
|
||||
SecurityAccessLog.category,
|
||||
)
|
||||
)
|
||||
permission_rows = permission_result.all()
|
||||
security_rows = security_result.all()
|
||||
buckets: dict[tuple[str, str, str], dict] = {}
|
||||
all_ip_addresses: set[str] = set()
|
||||
all_user_ids: set[str] = set()
|
||||
located_ip_addresses: set[str] = set()
|
||||
private_ip_addresses: set[str] = set()
|
||||
unknown_ip_addresses: set[str] = set()
|
||||
external_fallback_ip_addresses: set[str] = set()
|
||||
total_count = 0
|
||||
allowed_count = 0
|
||||
denied_count = 0
|
||||
@@ -1619,6 +1623,28 @@ async def get_ip_locations(
|
||||
resolved_locations: dict[str, IpLocation] = {}
|
||||
resolved_metadata: dict[str, GeoLocationMetadata] = {}
|
||||
|
||||
source_ip_addresses = list(
|
||||
dict.fromkeys(
|
||||
[str(row[0]) for row in permission_rows if row[0]]
|
||||
+ [str(row[0]) for row in security_rows if row[0]]
|
||||
)
|
||||
)
|
||||
for ip_address in source_ip_addresses:
|
||||
ip_info = resolve_ip_location(ip_address)
|
||||
resolved_locations[ip_address] = ip_info
|
||||
resolved_metadata[ip_address] = resolve_geo_location_metadata(ip_info)
|
||||
missing_coordinate_ips = [
|
||||
ip_address
|
||||
for ip_address, metadata in resolved_metadata.items()
|
||||
if metadata.accuracy_level != "private"
|
||||
and (metadata.longitude is None or metadata.latitude is None)
|
||||
]
|
||||
external_locations = await resolve_external_ip_locations(missing_coordinate_ips)
|
||||
for ip_address, external_location in external_locations.items():
|
||||
resolved_locations[ip_address] = external_location.merge_ip_location(resolved_locations[ip_address])
|
||||
resolved_metadata[ip_address] = external_location.to_metadata()
|
||||
external_fallback_ip_addresses.add(ip_address)
|
||||
|
||||
def normalize_observed_at(value: datetime | None) -> datetime | None:
|
||||
if value is None:
|
||||
return None
|
||||
@@ -1725,7 +1751,7 @@ async def get_ip_locations(
|
||||
if user_id:
|
||||
all_user_ids.add(str(user_id))
|
||||
|
||||
for ip_address, user_id, allowed, event_count, first_seen_at, last_seen_at in permission_result.all():
|
||||
for ip_address, user_id, allowed, event_count, first_seen_at, last_seen_at in permission_rows:
|
||||
add_ip_location_row(
|
||||
ip_address,
|
||||
user_id,
|
||||
@@ -1736,7 +1762,7 @@ async def get_ip_locations(
|
||||
source_kind="permission",
|
||||
)
|
||||
|
||||
for client_ip, user_identifier, auth_status, allowed, severity, category, event_count, first_seen_at, last_seen_at in security_result.all():
|
||||
for client_ip, user_identifier, auth_status, allowed, severity, category, event_count, first_seen_at, last_seen_at in security_rows:
|
||||
user_id = None
|
||||
if auth_status == "AUTHENTICATED" and user_identifier:
|
||||
try:
|
||||
@@ -1837,10 +1863,11 @@ async def get_ip_locations(
|
||||
},
|
||||
"server_location": server_location.to_public_dict() if server_location else None,
|
||||
"data_quality": {
|
||||
"resolver": "ip2region",
|
||||
"resolver": "ip2region+ip2location.io" if external_fallback_ip_addresses else "ip2region",
|
||||
"located_ip_count": located_count,
|
||||
"private_ip_count": private_count,
|
||||
"unknown_ip_count": unknown_count,
|
||||
"external_fallback_ip_count": len(external_fallback_ip_addresses),
|
||||
"location_coverage_rate": round((located_count / public_ip_count) * 100, 1) if public_ip_count else 100.0,
|
||||
"returned_region_count": len(items),
|
||||
},
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import uuid
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
@@ -10,7 +10,7 @@ from app.crud import user as user_crud
|
||||
from app.crud import member as member_crud
|
||||
from app.utils.pagination import paginate
|
||||
from app.schemas.user import LoginStatus, UserCreate, UserLoginActivityRead, UserRead, UserStatus, UserUpdate
|
||||
from app.services.user_login_sessions import get_login_summaries, list_login_activities
|
||||
from app.services.user_login_sessions import get_login_summaries, list_login_activities, login_activity_payload
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@@ -53,13 +53,16 @@ async def list_users(
|
||||
@router.get("/{user_id}/login-activities", response_model=list[UserLoginActivityRead])
|
||||
async def read_user_login_activities(
|
||||
user_id: uuid.UUID,
|
||||
response: Response,
|
||||
limit: int = Query(default=30, ge=1, le=100),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
current_user=Depends(require_roles(["ADMIN"])),
|
||||
) -> list[UserLoginActivityRead]:
|
||||
response.headers["Cache-Control"] = "no-store"
|
||||
if not await user_crud.get_by_id(db, user_id):
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="用户不存在")
|
||||
return await list_login_activities(db, user_id=user_id, limit=limit)
|
||||
activities = await list_login_activities(db, user_id=user_id, limit=limit)
|
||||
return [login_activity_payload(activity) for activity in activities]
|
||||
|
||||
|
||||
@router.post("/", response_model=UserRead, status_code=status.HTTP_201_CREATED)
|
||||
|
||||
@@ -40,6 +40,11 @@ class Settings(BaseSettings):
|
||||
)
|
||||
MONITORING_PUBLIC_IP_DISCOVERY_TIMEOUT_SECONDS: float = Field(default=2.5, ge=0.5, le=10)
|
||||
MONITORING_SERVER_LOCATION_CACHE_SECONDS: int = Field(default=86400, ge=300, le=604800)
|
||||
MONITORING_IP_GEO_FALLBACK_ENABLED: bool = True
|
||||
MONITORING_IP_GEO_FALLBACK_API_KEY: Optional[str] = None
|
||||
MONITORING_IP_GEO_FALLBACK_TIMEOUT_SECONDS: float = Field(default=2.5, ge=0.5, le=10)
|
||||
MONITORING_IP_GEO_FALLBACK_CACHE_SECONDS: int = Field(default=604800, ge=3600, le=2592000)
|
||||
MONITORING_IP_GEO_FALLBACK_MAX_LOOKUPS: int = Field(default=10, ge=1, le=100)
|
||||
MONITORING_ACCESS_LOG_RETENTION_DAYS: int = Field(default=90, ge=7, le=3650)
|
||||
MONITORING_METRIC_RETENTION_DAYS: int = Field(default=400, ge=30, le=3650)
|
||||
MONITORING_RETENTION_INTERVAL_SECONDS: int = Field(default=86400, ge=60, le=604800)
|
||||
|
||||
@@ -11,7 +11,7 @@ from app.db.base_class import Base
|
||||
|
||||
|
||||
class UserLoginSession(Base):
|
||||
"""Server-side login activity record without storing credentials or raw IPs."""
|
||||
"""Server-side login activity record without storing credentials."""
|
||||
|
||||
__tablename__ = "user_login_sessions"
|
||||
|
||||
@@ -26,6 +26,7 @@ class UserLoginSession(Base):
|
||||
client_platform: Mapped[str | None] = mapped_column(String(32), nullable=True)
|
||||
client_version: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
client_source: Mapped[str | None] = mapped_column(String(32), nullable=True)
|
||||
login_ip: Mapped[str | None] = mapped_column(String(45), nullable=True)
|
||||
login_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())
|
||||
last_seen_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())
|
||||
ended_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
@@ -89,10 +89,14 @@ class UserLoginActivityRead(BaseModel):
|
||||
client_type: Literal["web", "desktop"]
|
||||
client_platform: Optional[str] = None
|
||||
client_version: Optional[str] = None
|
||||
client_source: Optional[str] = None
|
||||
login_ip: Optional[str] = None
|
||||
ip_location: Optional[str] = None
|
||||
login_at: datetime
|
||||
last_seen_at: datetime
|
||||
ended_at: Optional[datetime] = None
|
||||
end_reason: Optional[str] = None
|
||||
activity_status: Literal["ONLINE", "OFFLINE", "ENDED"]
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
"""Bounded third-party coordinate fallback for public source IPs.
|
||||
|
||||
IPAddress.my identifies IP2Location.io as its data provider. We use the
|
||||
provider's documented JSON API instead of scraping the public HTML page.
|
||||
Only globally routable addresses are eligible, and results are cached so the
|
||||
monitoring UI does not turn into a per-refresh third-party lookup fan-out.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import ipaddress
|
||||
import logging
|
||||
import math
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Iterable
|
||||
|
||||
import httpx
|
||||
|
||||
from app.core.config import settings
|
||||
from app.services.geo_location_metadata import GeoLocationMetadata
|
||||
from app.services.ip_location import IpLocation
|
||||
|
||||
logger = logging.getLogger("ctms.ip_geolocation_fallback")
|
||||
|
||||
_API_URL = "https://api.ip2location.io/"
|
||||
_MAX_RESPONSE_BYTES = 64 * 1024
|
||||
_MAX_CACHE_ENTRIES = 4096
|
||||
_NEGATIVE_CACHE_SECONDS = 3600
|
||||
_cache: dict[str, tuple[float, "ExternalIpLocation | None"]] = {}
|
||||
_cache_lock = asyncio.Lock()
|
||||
|
||||
|
||||
def _text(value: Any, limit: int = 160) -> str:
|
||||
candidate = str(value or "").strip()
|
||||
return "" if candidate in {"", "-", "0"} else candidate[:limit]
|
||||
|
||||
|
||||
def _coordinate(value: Any, *, minimum: float, maximum: float) -> float | None:
|
||||
try:
|
||||
number = float(value)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
return number if math.isfinite(number) and minimum <= number <= maximum else None
|
||||
|
||||
|
||||
def _global_ip(value: str | None) -> str | None:
|
||||
try:
|
||||
address = ipaddress.ip_address((value or "").strip())
|
||||
except ValueError:
|
||||
return None
|
||||
return str(address) if address.is_global else None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ExternalIpLocation:
|
||||
ip_address: str
|
||||
country: str
|
||||
country_code: str
|
||||
region: str
|
||||
city: str
|
||||
isp: str
|
||||
longitude: float
|
||||
latitude: float
|
||||
|
||||
def merge_ip_location(self, local: IpLocation) -> IpLocation:
|
||||
country = self.country or local.country
|
||||
province = self.region or local.province
|
||||
city = self.city or local.city
|
||||
isp = self.isp or local.isp
|
||||
location = " / ".join(part for part in [country, province, city, isp] if part) or local.location
|
||||
return IpLocation(location=location, country=country, province=province, city=city, isp=isp)
|
||||
|
||||
def to_metadata(self) -> GeoLocationMetadata:
|
||||
return GeoLocationMetadata(
|
||||
country=self.country,
|
||||
country_code=self.country_code,
|
||||
region_code="",
|
||||
longitude=self.longitude,
|
||||
latitude=self.latitude,
|
||||
accuracy_level="city" if self.city else "country",
|
||||
)
|
||||
|
||||
|
||||
def _parse_response(expected_ip: str, payload: Any) -> ExternalIpLocation | None:
|
||||
if not isinstance(payload, dict):
|
||||
return None
|
||||
response_ip = _global_ip(_text(payload.get("ip"), 45))
|
||||
if response_ip != expected_ip:
|
||||
return None
|
||||
latitude = _coordinate(payload.get("latitude"), minimum=-90, maximum=90)
|
||||
longitude = _coordinate(payload.get("longitude"), minimum=-180, maximum=180)
|
||||
if latitude is None or longitude is None:
|
||||
return None
|
||||
country_code = _text(payload.get("country_code"), 2).upper()
|
||||
if len(country_code) != 2:
|
||||
country_code = ""
|
||||
return ExternalIpLocation(
|
||||
ip_address=expected_ip,
|
||||
country=_text(payload.get("country_name"), 100),
|
||||
country_code=country_code,
|
||||
region=_text(payload.get("region_name"), 100),
|
||||
city=_text(payload.get("city_name"), 100),
|
||||
isp=_text(payload.get("isp"), 160),
|
||||
longitude=longitude,
|
||||
latitude=latitude,
|
||||
)
|
||||
|
||||
|
||||
async def _fetch_one(
|
||||
client: httpx.AsyncClient,
|
||||
semaphore: asyncio.Semaphore,
|
||||
ip_address: str,
|
||||
) -> ExternalIpLocation | None:
|
||||
async with semaphore:
|
||||
try:
|
||||
response = await client.get(_API_URL, params={"ip": ip_address, "format": "json"})
|
||||
response.raise_for_status()
|
||||
if len(response.content) > _MAX_RESPONSE_BYTES:
|
||||
return None
|
||||
return _parse_response(ip_address, response.json())
|
||||
except (httpx.HTTPError, ValueError):
|
||||
logger.warning("External IP coordinate fallback unavailable")
|
||||
return None
|
||||
|
||||
|
||||
async def resolve_external_ip_locations(ip_addresses: Iterable[str]) -> dict[str, ExternalIpLocation]:
|
||||
if not settings.MONITORING_IP_GEO_FALLBACK_ENABLED or settings.ENV == "test":
|
||||
return {}
|
||||
|
||||
candidates = list(dict.fromkeys(filter(None, (_global_ip(value) for value in ip_addresses))))
|
||||
if not candidates:
|
||||
return {}
|
||||
|
||||
now = time.monotonic()
|
||||
resolved: dict[str, ExternalIpLocation] = {}
|
||||
pending: list[str] = []
|
||||
async with _cache_lock:
|
||||
for ip_address in candidates:
|
||||
cached = _cache.get(ip_address)
|
||||
if cached and cached[0] > now:
|
||||
if cached[1] is not None:
|
||||
resolved[ip_address] = cached[1]
|
||||
continue
|
||||
pending.append(ip_address)
|
||||
|
||||
pending = pending[: settings.MONITORING_IP_GEO_FALLBACK_MAX_LOOKUPS]
|
||||
if not pending:
|
||||
return resolved
|
||||
|
||||
headers = {"Accept": "application/json", "User-Agent": "CTMS-IP-Coordinate-Fallback/1.0"}
|
||||
api_key = (settings.MONITORING_IP_GEO_FALLBACK_API_KEY or "").strip()
|
||||
if api_key:
|
||||
headers["Authorization"] = f"Bearer {api_key}"
|
||||
timeout = httpx.Timeout(settings.MONITORING_IP_GEO_FALLBACK_TIMEOUT_SECONDS)
|
||||
semaphore = asyncio.Semaphore(min(5, len(pending)))
|
||||
async with httpx.AsyncClient(timeout=timeout, follow_redirects=False, headers=headers) as client:
|
||||
fetched = await asyncio.gather(*(_fetch_one(client, semaphore, item) for item in pending))
|
||||
|
||||
now = time.monotonic()
|
||||
async with _cache_lock:
|
||||
for ip_address, item in zip(pending, fetched):
|
||||
ttl = settings.MONITORING_IP_GEO_FALLBACK_CACHE_SECONDS if item else _NEGATIVE_CACHE_SECONDS
|
||||
_cache[ip_address] = (now + ttl, item)
|
||||
if item is not None:
|
||||
resolved[ip_address] = item
|
||||
while len(_cache) > _MAX_CACHE_ENTRIES:
|
||||
_cache.pop(next(iter(_cache)))
|
||||
return resolved
|
||||
|
||||
|
||||
def reset_ip_geolocation_fallback_cache() -> None:
|
||||
_cache.clear()
|
||||
@@ -21,6 +21,7 @@ import httpx
|
||||
|
||||
from app.core.config import settings
|
||||
from app.services.geo_location_metadata import resolve_geo_location_metadata
|
||||
from app.services.ip_geolocation_fallback import resolve_external_ip_locations
|
||||
from app.services.ip_location import resolve_ip_location
|
||||
|
||||
logger = logging.getLogger("ctms.monitoring_server_location")
|
||||
@@ -128,6 +129,11 @@ async def resolve_monitoring_server_location(*, force: bool = False) -> Monitori
|
||||
if public_ip:
|
||||
ip_location = resolve_ip_location(public_ip)
|
||||
metadata = resolve_geo_location_metadata(ip_location)
|
||||
if metadata.longitude is None or metadata.latitude is None:
|
||||
external_location = (await resolve_external_ip_locations([public_ip])).get(public_ip)
|
||||
if external_location is not None:
|
||||
ip_location = external_location.merge_ip_location(ip_location)
|
||||
metadata = external_location.to_metadata()
|
||||
if metadata.longitude is not None and metadata.latitude is not None:
|
||||
country = metadata.country or ip_location.country
|
||||
name = " / ".join(
|
||||
|
||||
@@ -20,6 +20,7 @@ from app.models.permission_access_log import PermissionAccessLog
|
||||
from app.models.security_access_log import SecurityAccessLog
|
||||
from app.models.source_location_snapshot import SourceLocationSnapshot
|
||||
from app.services.geo_location_metadata import resolve_geo_location_metadata
|
||||
from app.services.ip_geolocation_fallback import resolve_external_ip_locations
|
||||
from app.services.ip_location import resolve_ip_location
|
||||
|
||||
logger = logging.getLogger("ctms.source_location_aggregator")
|
||||
@@ -149,12 +150,31 @@ async def aggregate_source_location_hour(bucket_start: datetime, bucket_end: dat
|
||||
auth_failure_count=auth_failures,
|
||||
)
|
||||
|
||||
resolved_locations = {
|
||||
ip_address: resolve_ip_location(ip_address)
|
||||
for ip_address, _user_identity in aggregates
|
||||
}
|
||||
resolved_metadata = {
|
||||
ip_address: resolve_geo_location_metadata(ip_info)
|
||||
for ip_address, ip_info in resolved_locations.items()
|
||||
}
|
||||
missing_coordinate_ips = [
|
||||
ip_address
|
||||
for ip_address, metadata in resolved_metadata.items()
|
||||
if metadata.accuracy_level != "private"
|
||||
and (metadata.longitude is None or metadata.latitude is None)
|
||||
]
|
||||
external_locations = await resolve_external_ip_locations(missing_coordinate_ips)
|
||||
for ip_address, external_location in external_locations.items():
|
||||
resolved_locations[ip_address] = external_location.merge_ip_location(resolved_locations[ip_address])
|
||||
resolved_metadata[ip_address] = external_location.to_metadata()
|
||||
|
||||
await session.execute(
|
||||
delete(SourceLocationSnapshot).where(SourceLocationSnapshot.bucket_time == bucket_start)
|
||||
)
|
||||
for row in aggregates.values():
|
||||
ip_info = resolve_ip_location(row["ip_address"])
|
||||
metadata = resolve_geo_location_metadata(ip_info)
|
||||
ip_info = resolved_locations[row["ip_address"]]
|
||||
metadata = resolved_metadata[row["ip_address"]]
|
||||
longitude = metadata.longitude
|
||||
latitude = metadata.latitude
|
||||
country = metadata.country or ip_info.country
|
||||
|
||||
@@ -11,7 +11,9 @@ 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)
|
||||
@@ -59,6 +61,35 @@ def session_client_type(payload: dict[str, Any], headers: Any) -> str:
|
||||
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,
|
||||
*,
|
||||
@@ -75,6 +106,7 @@ async def create_login_session(
|
||||
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,
|
||||
)
|
||||
@@ -102,6 +134,7 @@ async def touch_login_session(
|
||||
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,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import pytest
|
||||
|
||||
from app.core.config import settings
|
||||
from app.services import ip_geolocation_fallback
|
||||
from app.services.ip_geolocation_fallback import ExternalIpLocation
|
||||
|
||||
|
||||
def test_ip2location_response_parser_requires_matching_public_ip_and_coordinates():
|
||||
result = ip_geolocation_fallback._parse_response(
|
||||
"8.8.8.8",
|
||||
{
|
||||
"ip": "8.8.8.8",
|
||||
"country_code": "US",
|
||||
"country_name": "United States of America",
|
||||
"region_name": "California",
|
||||
"city_name": "Mountain View",
|
||||
"latitude": 37.38605,
|
||||
"longitude": -122.08385,
|
||||
"isp": "Google LLC",
|
||||
},
|
||||
)
|
||||
|
||||
assert result is not None
|
||||
assert result.country_code == "US"
|
||||
assert result.longitude == -122.08385
|
||||
assert ip_geolocation_fallback._parse_response("8.8.8.8", {"ip": "1.1.1.1"}) is None
|
||||
assert ip_geolocation_fallback._global_ip("192.168.1.8") is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_external_fallback_caches_public_ip_results(monkeypatch):
|
||||
calls = []
|
||||
|
||||
async def fake_fetch(_client, _semaphore, ip_address):
|
||||
calls.append(ip_address)
|
||||
return ExternalIpLocation(
|
||||
ip_address=ip_address,
|
||||
country="United States of America",
|
||||
country_code="US",
|
||||
region="California",
|
||||
city="Mountain View",
|
||||
isp="Google LLC",
|
||||
longitude=-122.08385,
|
||||
latitude=37.38605,
|
||||
)
|
||||
|
||||
monkeypatch.setattr(settings, "ENV", "development")
|
||||
monkeypatch.setattr(settings, "MONITORING_IP_GEO_FALLBACK_ENABLED", True)
|
||||
monkeypatch.setattr(ip_geolocation_fallback, "_fetch_one", fake_fetch)
|
||||
ip_geolocation_fallback.reset_ip_geolocation_fallback_cache()
|
||||
|
||||
first = await ip_geolocation_fallback.resolve_external_ip_locations(["8.8.8.8", "192.168.1.8"])
|
||||
second = await ip_geolocation_fallback.resolve_external_ip_locations(["8.8.8.8"])
|
||||
|
||||
assert first["8.8.8.8"].city == "Mountain View"
|
||||
assert second["8.8.8.8"].latitude == 37.38605
|
||||
assert calls == ["8.8.8.8"]
|
||||
ip_geolocation_fallback.reset_ip_geolocation_fallback_cache()
|
||||
@@ -4,6 +4,7 @@ import pytest
|
||||
|
||||
from app.core.config import settings
|
||||
from app.services import monitoring_server_location
|
||||
from app.services.ip_geolocation_fallback import ExternalIpLocation
|
||||
|
||||
|
||||
def test_public_ip_normalization_rejects_private_addresses():
|
||||
@@ -51,3 +52,45 @@ async def test_server_location_returns_none_instead_of_a_hardcoded_fallback(monk
|
||||
|
||||
assert location is None
|
||||
monitoring_server_location.reset_monitoring_server_location_cache()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_server_location_uses_external_coordinates_when_local_coordinates_are_missing(monkeypatch):
|
||||
monkeypatch.setattr(settings, "MONITORING_SERVER_PUBLIC_IP", "5.34.216.210")
|
||||
monkeypatch.setattr(
|
||||
monitoring_server_location,
|
||||
"resolve_ip_location",
|
||||
lambda _ip: SimpleNamespace(
|
||||
location="公网",
|
||||
country="",
|
||||
province="",
|
||||
city="",
|
||||
isp="",
|
||||
),
|
||||
)
|
||||
|
||||
async def fake_external_lookup(ip_addresses):
|
||||
assert ip_addresses == ["5.34.216.210"]
|
||||
return {
|
||||
"5.34.216.210": ExternalIpLocation(
|
||||
ip_address="5.34.216.210",
|
||||
country="United States of America",
|
||||
country_code="US",
|
||||
region="California",
|
||||
city="Los Angeles",
|
||||
isp="Example ISP",
|
||||
longitude=-118.2439,
|
||||
latitude=34.05257,
|
||||
)
|
||||
}
|
||||
|
||||
monkeypatch.setattr(monitoring_server_location, "resolve_external_ip_locations", fake_external_lookup)
|
||||
monitoring_server_location.reset_monitoring_server_location_cache()
|
||||
|
||||
location = await monitoring_server_location.resolve_monitoring_server_location()
|
||||
|
||||
assert location is not None
|
||||
assert location.name == "United States of America / California / Los Angeles"
|
||||
assert location.longitude == -118.2439
|
||||
assert location.latitude == 34.05257
|
||||
monitoring_server_location.reset_monitoring_server_location_cache()
|
||||
|
||||
@@ -13,6 +13,7 @@ from app.core.permission_monitor import set_permission_monitor, PermissionMonito
|
||||
from app.api.v1 import permission_monitoring
|
||||
from app.api.v1.system_permissions import list_system_permissions
|
||||
from app.services.monitoring_server_location import MonitoringServerLocation
|
||||
from app.services.ip_geolocation_fallback import ExternalIpLocation
|
||||
|
||||
|
||||
class FakeIpInfo:
|
||||
@@ -583,6 +584,57 @@ async def test_ip_locations_counts_unique_users_per_location(db_session, monkeyp
|
||||
assert sorted(resolved_ips) == ["10.1.1.1", "10.1.1.2", "10.1.1.3"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ip_locations_uses_external_coordinates_only_when_local_coordinates_are_missing(db_session, monkeypatch):
|
||||
await db_session.execute(text("DELETE FROM permission_access_logs"))
|
||||
await db_session.execute(text("DELETE FROM security_access_logs"))
|
||||
await db_session.commit()
|
||||
study_id = uuid.uuid4()
|
||||
user_id = uuid.uuid4()
|
||||
await _seed_permission_log(db_session, study_id, user_id, allowed=True, elapsed_ms=3.2)
|
||||
await db_session.execute(
|
||||
text("UPDATE permission_access_logs SET ip_address = :ip_address"),
|
||||
{"ip_address": "8.8.8.8"},
|
||||
)
|
||||
await db_session.commit()
|
||||
|
||||
monkeypatch.setattr(
|
||||
permission_monitoring,
|
||||
"resolve_ip_location",
|
||||
lambda _ip: FakeIpInfo("", "", isp="", country="未知国家"),
|
||||
)
|
||||
|
||||
async def fake_external_lookup(ip_addresses):
|
||||
assert ip_addresses == ["8.8.8.8"]
|
||||
return {
|
||||
"8.8.8.8": ExternalIpLocation(
|
||||
ip_address="8.8.8.8",
|
||||
country="United States of America",
|
||||
country_code="US",
|
||||
region="California",
|
||||
city="Mountain View",
|
||||
isp="Google LLC",
|
||||
longitude=-122.08385,
|
||||
latitude=37.38605,
|
||||
)
|
||||
}
|
||||
|
||||
monkeypatch.setattr(permission_monitoring, "resolve_external_ip_locations", fake_external_lookup)
|
||||
|
||||
result = await permission_monitoring.get_ip_locations(
|
||||
db=db_session,
|
||||
_=AdminUserStub(),
|
||||
days=7,
|
||||
limit=10,
|
||||
)
|
||||
|
||||
assert result["items"][0]["longitude"] == -122.08385
|
||||
assert result["items"][0]["latitude"] == 37.38605
|
||||
assert result["items"][0]["city"] == "Mountain View"
|
||||
assert result["data_quality"]["external_fallback_ip_count"] == 1
|
||||
assert result["data_quality"]["resolver"] == "ip2region+ip2location.io"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ip_locations_merges_same_region_with_different_isp(db_session, monkeypatch):
|
||||
"""同一省市的 IP 属地统计不应因运营商不同拆分。"""
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from datetime import datetime, timedelta, timezone
|
||||
import uuid
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
@@ -8,7 +9,13 @@ from app.core.config import settings
|
||||
from app.crud import user as user_crud
|
||||
from app.models.user import User, UserStatus
|
||||
from app.models.user_login_session import UserLoginSession
|
||||
from app.services.user_login_sessions import get_login_summaries, session_id_from_payload
|
||||
from app.schemas.user import UserLoginActivityRead
|
||||
from app.services.user_login_sessions import (
|
||||
create_login_session,
|
||||
get_login_summaries,
|
||||
login_activity_payload,
|
||||
session_id_from_payload,
|
||||
)
|
||||
|
||||
|
||||
def test_legacy_session_id_is_stable_without_storing_a_token():
|
||||
@@ -28,6 +35,63 @@ def test_session_heartbeat_returns_server_observed_client_ip():
|
||||
assert '"client_ip": resolve_client_ip(request)' in auth_source
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_login_session_stores_server_observed_login_ip(db_session):
|
||||
user = User(
|
||||
id=uuid.uuid4(),
|
||||
email="login-ip@example.com",
|
||||
password_hash="hash",
|
||||
full_name="Login IP",
|
||||
clinical_department="IT",
|
||||
status=UserStatus.ACTIVE,
|
||||
)
|
||||
db_session.add(user)
|
||||
await db_session.commit()
|
||||
request = SimpleNamespace(
|
||||
headers={
|
||||
"x-ctms-client-type": "desktop",
|
||||
"x-ctms-client-platform": "macos",
|
||||
"x-ctms-client-version": "0.1.0",
|
||||
},
|
||||
client=SimpleNamespace(host="203.0.113.18"),
|
||||
)
|
||||
|
||||
session = await create_login_session(
|
||||
db_session,
|
||||
session_id=uuid.uuid4(),
|
||||
user_id=user.id,
|
||||
request=request,
|
||||
)
|
||||
|
||||
assert session.login_ip == "203.0.113.18"
|
||||
assert session.client_type == "desktop"
|
||||
|
||||
|
||||
def test_login_activity_payload_uses_server_authoritative_status_and_local_ip_location(monkeypatch):
|
||||
now = datetime.now(timezone.utc)
|
||||
monkeypatch.setattr(settings, "USER_SESSION_ONLINE_SECONDS", 300)
|
||||
session = UserLoginSession(
|
||||
id=uuid.uuid4(),
|
||||
user_id=uuid.uuid4(),
|
||||
client_type="web",
|
||||
login_ip="127.0.0.1",
|
||||
login_at=now - timedelta(minutes=10),
|
||||
last_seen_at=now - timedelta(seconds=30),
|
||||
)
|
||||
|
||||
online = login_activity_payload(session, reference_time=now)
|
||||
assert online["activity_status"] == "ONLINE"
|
||||
assert online["login_ip"] == "127.0.0.1"
|
||||
assert online["ip_location"] == "本机"
|
||||
assert UserLoginActivityRead.model_validate(online).activity_status == "ONLINE"
|
||||
|
||||
session.last_seen_at = now - timedelta(minutes=6)
|
||||
assert login_activity_payload(session, reference_time=now)["activity_status"] == "OFFLINE"
|
||||
|
||||
session.ended_at = now - timedelta(minutes=1)
|
||||
assert login_activity_payload(session, reference_time=now)["activity_status"] == "ENDED"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_login_summary_marks_recent_unended_sessions_online(db_session, monkeypatch):
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
Reference in New Issue
Block a user