"""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 )