Improved client IP and UA handling.
This commit is contained in:
@@ -53,7 +53,7 @@
|
||||
<!-- Device info display (shown when 3 words match a request) -->
|
||||
<div v-else-if="deviceInfo" class="device-info">
|
||||
<p class="device-permit-text">Permit {{ deviceInfo.action === 'register' ? 'registration' : 'login' }} to <strong>{{ deviceInfo.host }}</strong></p>
|
||||
<p class="device-meta">{{ deviceInfo.user_agent_pretty }}</p>
|
||||
<p class="device-meta">{{ deviceInfo.user_agent_pretty || '—' }}</p>
|
||||
|
||||
<p v-if="error" class="error-message" style="margin-top: 0.5rem;">{{ error }}</p>
|
||||
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
@keydown="handleItemKeydown($event, session)"
|
||||
>
|
||||
<div class="item-top">
|
||||
<h4 class="item-title">{{ session.user_agent }}</h4>
|
||||
<h4 class="item-title">{{ session.user_agent || '—' }}</h4>
|
||||
<div class="item-actions">
|
||||
<span v-if="session.is_current && !hoveredIp && !hoveredCredentialUuid" class="badge badge-current">Current</span>
|
||||
<span v-else-if="hoveredSession?.id === session.id" class="badge badge-current">Selected</span>
|
||||
|
||||
@@ -17,7 +17,7 @@ from paskia import db
|
||||
from paskia.authsession import EXPIRES, expires, get_reset
|
||||
from paskia.fastapi import authz, session, user
|
||||
from paskia.fastapi.response import MsgspecResponse
|
||||
from paskia.fastapi.session import AUTH_COOKIE, AUTH_COOKIE_NAME
|
||||
from paskia.fastapi.session import AUTH_COOKIE, AUTH_COOKIE_NAME, get_client_ip
|
||||
from paskia.globals import passkey as global_passkey
|
||||
from paskia.util import hostutil, htmlutil, passphrase, userinfo, vitedev
|
||||
|
||||
@@ -91,7 +91,7 @@ async def validate_token(
|
||||
if not timedelta(0) < consumed < _REFRESH_INTERVAL:
|
||||
db.update_session(
|
||||
auth,
|
||||
ip=request.client.host if request.client else "",
|
||||
ip=get_client_ip(request),
|
||||
user_agent=request.headers.get("user-agent") or "",
|
||||
expiry=expires(),
|
||||
ctx=ctx,
|
||||
|
||||
@@ -38,10 +38,33 @@ _AUTHZ_GRANTED = "\033[0;32m" # Granted scope (green)
|
||||
|
||||
|
||||
def format_ipv6_network(ip: str) -> str:
|
||||
"""Format IPv6 address to show only network part (first 64 bits)."""
|
||||
"""Format IPv6 address to show only network part (first 64 bits).
|
||||
|
||||
Special addresses are returned as-is for clarity:
|
||||
- ::1 (loopback)
|
||||
- :: (unspecified)
|
||||
- ::ffff:x.x.x.x (IPv4-mapped, returns just the IPv4 part)
|
||||
- fe80:: (link-local, returned as-is since interface-specific)
|
||||
"""
|
||||
try:
|
||||
# Strip brackets that some proxies add around IPv6
|
||||
ip = ip.strip("[]")
|
||||
# Strip zone ID (e.g., fe80::1%eth0)
|
||||
if "%" in ip:
|
||||
ip = ip.split("%")[0]
|
||||
addr = IPv6Address(ip)
|
||||
# Get the integer representation and mask to first 64 bits
|
||||
|
||||
# Special cases - return as-is or with minimal processing
|
||||
if addr.is_loopback: # ::1
|
||||
return "::1"
|
||||
if addr.is_unspecified: # ::
|
||||
return "::"
|
||||
if addr.ipv4_mapped: # ::ffff:x.x.x.x
|
||||
return str(addr.ipv4_mapped)
|
||||
if addr.is_link_local: # fe80::/10 - interface-specific, keep full
|
||||
return str(addr)
|
||||
|
||||
# Regular addresses: truncate to /64 network prefix
|
||||
network_int = int(addr) >> 64
|
||||
# Format as IPv6 with trailing ::
|
||||
# Split into 4 groups of 16 bits
|
||||
@@ -61,7 +84,9 @@ def format_client_ip(ip: str) -> str:
|
||||
"""Format client IP, compressing IPv6 to network part only."""
|
||||
if not ip or ip == "-":
|
||||
return "-"
|
||||
if ":" in ip:
|
||||
# Strip brackets for detection (some proxies add them)
|
||||
stripped = ip.strip("[]")
|
||||
if ":" in stripped:
|
||||
return format_ipv6_network(ip)
|
||||
return ip
|
||||
|
||||
|
||||
@@ -8,6 +8,8 @@ This module provides FastAPI-specific session management functionality:
|
||||
Generic session management functions have been moved to authsession.py
|
||||
"""
|
||||
|
||||
from ipaddress import IPv4Address, IPv6Address
|
||||
|
||||
from fastapi import Cookie, Request, Response, WebSocket
|
||||
|
||||
from paskia.authsession import EXPIRES
|
||||
@@ -16,10 +18,44 @@ AUTH_COOKIE_NAME = "__Host-paskia"
|
||||
AUTH_COOKIE = Cookie(None, alias=AUTH_COOKIE_NAME)
|
||||
|
||||
|
||||
def normalize_ip(ip: str) -> str:
|
||||
"""Normalize IP address, stripping brackets and validating format.
|
||||
|
||||
Proxies may pass IPv6 in brackets like [::1] or with zone IDs.
|
||||
IPv4-mapped IPv6 addresses (::ffff:x.x.x.x) are converted to plain IPv4.
|
||||
Returns empty string for invalid addresses.
|
||||
"""
|
||||
if not ip:
|
||||
return ""
|
||||
# Strip brackets that some proxies add around IPv6
|
||||
ip = ip.strip("[]")
|
||||
# Strip zone ID (e.g., fe80::1%eth0)
|
||||
if "%" in ip:
|
||||
ip = ip.split("%")[0]
|
||||
try:
|
||||
# Validate and normalize
|
||||
if ":" in ip:
|
||||
addr = IPv6Address(ip)
|
||||
# Convert IPv4-mapped addresses to plain IPv4
|
||||
if addr.ipv4_mapped:
|
||||
return str(addr.ipv4_mapped)
|
||||
return str(addr)
|
||||
return str(IPv4Address(ip))
|
||||
except ValueError:
|
||||
return ip # Return as-is if not a valid IP (could be hostname)
|
||||
|
||||
|
||||
def get_client_ip(request: Request | WebSocket) -> str:
|
||||
"""Get client IP from request, normalized."""
|
||||
if not request.client:
|
||||
return ""
|
||||
return normalize_ip(request.client.host)
|
||||
|
||||
|
||||
def infodict(request: Request | WebSocket, type: str) -> dict:
|
||||
"""Extract client information from request."""
|
||||
return {
|
||||
"ip": request.client.host if request.client else "",
|
||||
"ip": get_client_ip(request),
|
||||
"user_agent": request.headers.get("user-agent", "")[:500],
|
||||
"session_type": type,
|
||||
}
|
||||
|
||||
@@ -1,10 +1,29 @@
|
||||
import user_agents
|
||||
from ua_parser import parse
|
||||
|
||||
|
||||
def compact_user_agent(ua: str | None) -> str:
|
||||
if not ua:
|
||||
return "-"
|
||||
u = user_agents.parse(ua)
|
||||
ver = u.browser.version_string.split(".")[0]
|
||||
dev = u.device.family if u.device.family not in ["Other", "Mac"] else ""
|
||||
return f"{u.browser.family}/{ver} {u.os.family} {dev}".strip()
|
||||
"""Format user agent string into a compact display format.
|
||||
|
||||
Returns empty string for empty/missing user agents.
|
||||
Returns original UA for unrecognized ones.
|
||||
"""
|
||||
if not ua or not ua.strip() or ua == "-":
|
||||
return ""
|
||||
r = parse(ua)
|
||||
browser = r.user_agent.family if r.user_agent else None
|
||||
ver = r.user_agent.major if r.user_agent else ""
|
||||
os_name = r.os.family if r.os else None
|
||||
dev = r.device.family if r.device else None
|
||||
# If browser is unrecognized, return original UA
|
||||
if browser in (None, "Other") and os_name in (None, "Other"):
|
||||
return ua
|
||||
# Filter out "Other" values
|
||||
browser = browser if browser and browser != "Other" else ""
|
||||
os_name = os_name if os_name and os_name != "Other" else ""
|
||||
# Exclude device if it's "Other" or matches browser family (parser bug)
|
||||
if dev in (None, "Other") or dev == browser:
|
||||
dev = ""
|
||||
# Build compact string, filtering empty parts
|
||||
parts = [f"{browser}/{ver}" if browser else "", os_name, dev]
|
||||
result = " ".join(p for p in parts if p).strip()
|
||||
return result
|
||||
|
||||
+1
-1
@@ -18,11 +18,11 @@ dependencies = [
|
||||
"base64url>=1.0.0",
|
||||
"uuid7-standard>=1.0.0",
|
||||
"pyjwt>=2.8.0",
|
||||
"user-agents>=2.2.0",
|
||||
"jsondiff>=2.2.1",
|
||||
"msgspec>=0.20.0",
|
||||
"aiofiles>=25.1.0",
|
||||
"fastapi-vue>=0.3.0",
|
||||
"ua-parser[regex]>=1.0.1",
|
||||
]
|
||||
requires-python = ">=3.11"
|
||||
|
||||
|
||||
Reference in New Issue
Block a user