Files
Kareem Horstink 331de4bc9e
Build Android APK / build (push) Failing after 4m21s
Build Server .exe / build (push) Failing after 2m34s
Add settings page, PIN manager, and auto-config
- New settings page at /settings (localhost-only) accessible via tray icon
- PIN manager with 6-digit codes, 5-min TTL, single-use
- API token uses secrets.token_urlsafe(32) for 256-bit entropy
- Config auto-creates config.yaml on first run with detected local IP
- Default PIN and token generated randomly on first startup
- Added pin_ttl setting (default 300s)
- Updated build_exe.py for onefile exe with settings assets bundled
- Added start.bat and start.sh for easy development startup
2026-07-03 08:21:46 +00:00

142 lines
3.7 KiB
Python

"""PIN manager for PaperDash.
Generates time-limited, single-use PINs for WebSocket authentication.
"""
from __future__ import annotations
import logging
import secrets
import threading
import time
from dataclasses import dataclass
from typing import Optional
logger = logging.getLogger("paperdash.pin_manager")
@dataclass
class PinEntry:
"""A generated PIN with expiration and usage tracking."""
pin: str
created_at: float
expires_at: float
used: bool = False
class PinManager:
"""Manages time-limited, single-use PINs."""
def __init__(self, ttl_seconds: int = 300) -> None:
"""
Args:
ttl_seconds: How long a PIN remains valid (default: 5 minutes)
"""
self.ttl_seconds = ttl_seconds
self.pins: dict[str, PinEntry] = {}
self._lock = threading.Lock()
self._cleanup_timer: Optional[threading.Timer] = None
def generate(self) -> str:
"""Generate a new 6-digit PIN.
Returns:
The generated PIN string.
"""
pin = secrets.token_hex(3)[:6] # 6 random hex digits
now = time.time()
entry = PinEntry(
pin=pin,
created_at=now,
expires_at=now + self.ttl_seconds,
used=False,
)
with self._lock:
self.pins[pin] = entry
logger.info(f"Generated PIN {pin} (expires in {self.ttl_seconds}s)")
return pin
def validate(self, pin: str) -> bool:
"""Validate a PIN (marks it as used if valid).
Args:
pin: The PIN to validate.
Returns:
True if the PIN is valid and not yet used.
"""
with self._lock:
entry = self.pins.get(pin)
if entry is None:
return False
# Check expiration
if time.time() > entry.expires_at:
del self.pins[pin]
logger.info(f"PIN {pin} expired")
return False
# Check if already used
if entry.used:
logger.info(f"PIN {pin} already used")
return False
# Mark as used
entry.used = True
return True
def cleanup_expired(self) -> int:
"""Remove expired PINs.
Returns:
Number of PINs removed.
"""
now = time.time()
expired = [pin for pin, entry in self.pins.items() if now > entry.expires_at]
with self._lock:
for pin in expired:
del self.pins[pin]
if expired:
logger.info(f"Cleaned up {len(expired)} expired PIN(s)")
return len(expired)
def start_cleanup_loop(self) -> None:
"""Start periodic cleanup of expired PINs (every 60 seconds)."""
self._run_cleanup()
def _run_cleanup(self) -> None:
"""Run one cleanup cycle and schedule the next."""
self.cleanup_expired()
with self._lock:
if self._cleanup_timer is not None:
self._cleanup_timer.cancel()
self._cleanup_timer = threading.Timer(60, self._run_cleanup)
self._cleanup_timer.daemon = True
self._cleanup_timer.start()
def stop(self) -> None:
"""Stop the cleanup loop."""
with self._lock:
if self._cleanup_timer is not None:
self._cleanup_timer.cancel()
self._cleanup_timer = None
def get_active_count(self) -> int:
"""Get the number of active (non-expired, unused) PINs."""
now = time.time()
with self._lock:
return sum(
1
for entry in self.pins.values()
if now <= entry.expires_at and not entry.used
)