diff --git a/frontend/src/components/RemoteAuthPermit.vue b/frontend/src/components/RemoteAuthPermit.vue index 4f9f951..6496e3b 100644 --- a/frontend/src/components/RemoteAuthPermit.vue +++ b/frontend/src/components/RemoteAuthPermit.vue @@ -53,7 +53,7 @@

Permit {{ deviceInfo.action === 'register' ? 'registration' : 'login' }} to {{ deviceInfo.host }}

-

{{ deviceInfo.user_agent_pretty }}

+

{{ deviceInfo.user_agent_pretty || '—' }}

{{ error }}

diff --git a/frontend/src/components/SessionList.vue b/frontend/src/components/SessionList.vue index 2f0c980..f538df1 100644 --- a/frontend/src/components/SessionList.vue +++ b/frontend/src/components/SessionList.vue @@ -30,7 +30,7 @@ @keydown="handleItemKeydown($event, session)" >
-

{{ session.user_agent }}

+

{{ session.user_agent || '—' }}

Current Selected diff --git a/paskia/fastapi/api.py b/paskia/fastapi/api.py index 9ebd3a1..1af4265 100644 --- a/paskia/fastapi/api.py +++ b/paskia/fastapi/api.py @@ -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, diff --git a/paskia/fastapi/logging.py b/paskia/fastapi/logging.py index 09f8e6c..731174f 100644 --- a/paskia/fastapi/logging.py +++ b/paskia/fastapi/logging.py @@ -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 diff --git a/paskia/fastapi/session.py b/paskia/fastapi/session.py index e86b207..69a3696 100644 --- a/paskia/fastapi/session.py +++ b/paskia/fastapi/session.py @@ -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, } diff --git a/paskia/util/useragent.py b/paskia/util/useragent.py index 418d004..fdb8e1d 100644 --- a/paskia/util/useragent.py +++ b/paskia/util/useragent.py @@ -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 diff --git a/pyproject.toml b/pyproject.toml index fb2cd22..b6ebf03 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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"