"""Authentification Blink : session enregistrée, identifiants, 2FA. Extrait de blink2video.py à l'étape 8 du plan de remédiation (AUDIT-2026-08-13.md, section 20, 8.1) : ce fichier ne connaît que la session, jamais les clips ni le registre. Il suppose que runtime.bootstrap() a déjà tourné (l'appelant décide quand : voir blink_cli.py et O-06/8.7).""" from __future__ import annotations # Python 3.8 (build Windows 7) : les annotations "X | None" ne s'évaluent qu'à l'écriture des chaînes, jamais à l'exécution. import asyncio from contextlib import asynccontextmanager import getpass import json import os import ssl import stat as stat_module import time import uuid import runtime import blink_tls import certifi from aiohttp import ClientSession, TCPConnector from blinkpy.auth import Auth, BlinkTwoFARequiredError from blinkpy.blinkpy import Blink # I-01 : merge_daily.py, serve.py et watch.py dérivent déjà leurs chemins par # défaut de runtime.app_dir() (BASE_DIR) ; blink2video.py était le seul des # quatre à utiliser des chemins relatifs, résolus au hasard du répertoire # courant du lancement plutôt que de la racine de données. BASE_DIR = runtime.app_dir() CONFIG = BASE_DIR / "blink_auth.json" # I-02/4.5 : liste blanche des champs persistés. blink.auth.login_attributes # renvoie self.data au complet, qui contient encore username/password tels # que passés à Auth() ; s'y fier sans filtrer laisserait fuir le mot de passe # dès qu'un champ de plus y apparaîtrait un jour côté blinkpy. AUTH_FIELDS = ( "username", "token", "expires_in", "expiration_date", "refresh_token", "host", "region_id", "client_id", "account_id", "user_id", "hardware_id", ) LIBELLES = { "fr": { "session_illisible_fichier": "Session illisible [fichier]. Une nouvelle connexion est nécessaire.", "session_illisible_json": "Session illisible [données JSON]. Une nouvelle connexion est nécessaire.", "session_illisible_schema": "Session illisible [schéma JSON]. Une nouvelle connexion est nécessaire.", "connexion_titre": "\nConnexion au compte Blink", "prompt_email": "Adresse e-mail : ", "prompt_password": "Mot de passe (non affiché) : ", "prompt_code": "Code Blink : ", "code_envoye": "\nUn code de vérification Blink vient d'être envoyé.", "code_refuse": "Code refusé. {remaining} tentative(s) restante(s).", "session_reutilisee": "Réutilisation de la session enregistrée dans {config}...", "session_invalide": "La session enregistrée n'est plus valide.", }, "en": { "session_illisible_fichier": "Session unreadable [file]. Signing in again is required.", "session_illisible_json": "Session unreadable [JSON data]. Signing in again is required.", "session_illisible_schema": "Session unreadable [JSON structure]. Signing in again is required.", "connexion_titre": "\nSigning in to the Blink account", "prompt_email": "Email address: ", "prompt_password": "Password (hidden): ", "prompt_code": "Blink code: ", "code_envoye": "\nA Blink verification code was just sent.", "code_refuse": "Code rejected. {remaining} attempt(s) left.", "session_reutilisee": "Reusing the session saved in {config}...", "session_invalide": "The saved session is no longer valid.", }, } def _(cle: str, **valeurs) -> str: return runtime.traduire(LIBELLES, cle, **valeurs) def contexte_tls() -> ssl.SSLContext: """Étend les racines système avec le magasin Mozilla livré par certifi. Windows 7 ne reçoit plus forcément les nouvelles autorités racines. On conserve son magasin (notamment utile derrière un proxy d'entreprise) et on lui ajoute celui de certifi, sans jamais désactiver la validation TLS ni le contrôle du nom d'hôte. """ return blink_tls.contexte_tls() def session_http() -> ClientSession: """Crée une session aiohttp dont toutes les requêtes utilisent ce magasin.""" return ClientSession(connector=TCPConnector(ssl=contexte_tls())) @asynccontextmanager async def session_http_temporaire(): """Ferme aussi le transport SSL avant que la boucle courte disparaisse.""" session = session_http() try: yield session finally: await session.close() # aiohttp recommande 250 ms pour laisser asyncio fermer ses transports # SSL ; nécessaire notamment avec la ProactorEventLoop de Python 3.8. await asyncio.sleep(0.250) def load_saved_session() -> dict | None: """Charge une session Blink sans jamais réutiliser un mot de passe stocké.""" if not CONFIG.exists(): return None try: data = json.loads(CONFIG.read_text(encoding="utf-8")) except OSError: print(_("session_illisible_fichier")) return None except json.JSONDecodeError: print(_("session_illisible_json")) return None if not isinstance(data, dict): print(_("session_illisible_schema")) return None if not isinstance(data.get("refresh_token"), str) or not data["refresh_token"]: return None # Auth.startup() exige ces clés, même lorsqu'un refresh_token est présent. # Une chaîne vide évite de conserver le mot de passe sur disque. data["username"] = data.get("username", "") data["password"] = "" return data def ask_credentials() -> dict: """Demande les identifiants dans le terminal.""" print(_("connexion_titre")) username = input(_("prompt_email")).strip() password = getpass.getpass(_("prompt_password")) return {"username": username, "password": password} def make_blink(session: ClientSession, login_data: dict) -> Blink: """Construit un client Blink avec un flux d'authentification explicite. I-03 : Auth rafraîchit son jeton en mémoire au fil des requêtes (voir Auth.query dans blinkpy), sans jamais l'écrire sur disque de lui-même. Une boucle de fond peut tourner des heures : sans persistance à chaque rafraîchissement, un crash ou un simple redémarrage retombe sur un jeton périmé. `callback` est le point d'extension prévu par blinkpy pour cela.""" blink = Blink(session=session) blink.auth = Auth(login_data, no_prompt=True, session=session, callback=lambda: save_session(blink)) return blink async def prompt_2fa_code(attempt: int) -> str: """Demande le code de vérification dans le terminal.""" return input(_("prompt_code")).strip() async def finish_login(blink: Blink, ask_code=None) -> bool: """Démarre Blink et termine éventuellement la vérification en deux étapes. `ask_code` permet de poser la question ailleurs que dans le terminal : serve.py y branche un formulaire de navigateur. C'est une coroutine pour que l'attente du code n'immobilise pas la boucle d'événements pendant que la session Blink reste ouverte.""" try: connected = await blink.start() except BlinkTwoFARequiredError: ask_code = ask_code or prompt_2fa_code print(_("code_envoye")) for attempt in range(3): code = (await ask_code(attempt) or "").strip() if code and await blink.send_2fa_code(code): return True remaining = 2 - attempt if remaining: print(_("code_refuse", remaining=remaining)) return False return bool(connected) async def connect_saved(session: ClientSession): """Ouvre une session Blink à partir du fichier enregistré, sans rien demander. Distinct de `connect`, qui se rabat sur le terminal quand la session n'est plus valable : derrière un serveur il n'y a personne pour répondre, mieux vaut renvoyer None et laisser l'appelant proposer une reconnexion.""" saved = load_saved_session() if not saved: return None blink = make_blink(session, saved) if not await finish_login(blink, ask_code=_no_code): return None save_session(blink) return blink async def _no_code(attempt: int) -> str: """Refuse la vérification en deux étapes hors session interactive.""" return "" async def login(session: ClientSession, username: str, password: str, ask_code=None): """Ouvre une session Blink à partir d'identifiants déjà recueillis. Voie d'entrée de serve.py, qui les collecte dans le navigateur. Le mot de passe ne sert qu'ici : `save_session` ne l'écrit jamais sur disque.""" blink = make_blink(session, {"username": username, "password": password}) if not await finish_login(blink, ask_code): return None save_session(blink) return blink def save_session(blink: Blink) -> None: """Sauvegarde les jetons de session, jamais le mot de passe Blink. Deux à quatre processus peuvent se connecter en parallèle au démarrage (download USB, download cloud, watch, serve), et ce callback est aussi celui que l'auto-rafraîchissement de blinkpy déclenche en cours de route (voir make_blink, I-03) : deux écritures peuvent donc se chevaucher sans ordre garanti. Trois précautions : un temporaire propre à ce processus (I-02, l'ancien `blink_auth.tmp` était partagé par tous), un horodatage interne qui empêche une sauvegarde en retard d'écraser une plus récente déjà sur disque, et - depuis la revue de code du 0eab463, bug #7 - un verrou (`runtime.verrou`, déjà utilisé ailleurs pour le Sync Module et l'assemblage) qui rend la lecture-puis-décision-puis-écriture atomique : sans lui, deux processus pouvaient chacun lire l'ancien contenu, chacun conclure « le mien est plus récent », puis écrire dans un ordre non garanti - celui qui finit en second écrase le jeton le plus récent, quel que soit l'horodatage qu'il portait.""" complet = blink.auth.login_attributes data = {champ: complet[champ] for champ in AUTH_FIELDS if champ in complet} data["updated_at"] = time.time() try: with runtime.verrou("session-save", f"blink_auth-{os.getpid()}", attente=5): precedent = None if CONFIG.exists(): try: precedent = json.loads(CONFIG.read_text(encoding="utf-8")) except (OSError, json.JSONDecodeError): precedent = None if isinstance(precedent, dict): # Un updated_at mal formé (fichier trafiqué à la main, JSON # partiellement écrit puis récupéré) ne doit pas empêcher la # sauvegarde : traité comme aucune préférence connue (0), # pas comme une exception qui remonte jusqu'au callback de # rafraîchissement automatique de Blink (revue du 27/08, # bug 7). try: precedent_updated_at = float(precedent.get("updated_at") or 0) except (TypeError, ValueError): precedent_updated_at = 0 if precedent_updated_at > data["updated_at"]: return CONFIG.parent.mkdir(parents=True, exist_ok=True) temporary = CONFIG.with_name( f"{CONFIG.stem}.{os.getpid()}.{uuid.uuid4().hex[:8]}.tmp" ) try: temporary.write_text(json.dumps(data, indent=2), encoding="utf-8") if os.name != "nt": os.chmod(temporary, stat_module.S_IRUSR | stat_module.S_IWUSR) temporary.replace(CONFIG) finally: temporary.unlink(missing_ok=True) except runtime.BusyError: # Un autre processus tient le verrou depuis plus de 5 s pour une # simple écriture de jeton : plus probable qu'il vient de finir la # sienne (déjà à jour) qu'un vrai blocage - abandonner ici plutôt que # de faire attendre l'appelant plus longtemps pour une sauvegarde # redondante. pass async def connect(session: ClientSession) -> Blink | None: """Ouvre une session Blink, avec reconnexion interactive en dernier recours.""" saved_session = load_saved_session() if saved_session: print(_("session_reutilisee", config=CONFIG)) blink = make_blink(session, saved_session) connected = await finish_login(blink) if not connected: print(_("session_invalide")) blink = make_blink(session, ask_credentials()) connected = await finish_login(blink) else: blink = make_blink(session, ask_credentials()) connected = await finish_login(blink) if not connected: return None save_session(blink) return blink async def preflight() -> dict: """Contrôle read-only de la session enregistrée (E-01, section 17.3). Strictement sans effet de bord : aucun armement, direct, manifeste USB, téléchargement, assemblage ni notification. Sert uniquement à décider si « start » peut continuer directement ou doit d'abord passer par l'onboarding web.""" etat = {"authenticated": False, "networks": 0, "sync_modules": 0, "cameras": 0, "cloud_only": False, "error": None} try: async with session_http_temporaire() as session: blink = await connect_saved(session) if blink is None: return etat etat["authenticated"] = True synchronisations = getattr(blink, "sync", None) or {} etat["sync_modules"] = len(synchronisations) etat["cloud_only"] = not synchronisations reseaux = set() for sync in synchronisations.values(): etat["cameras"] += len(getattr(sync, "cameras", None) or {}) reseau = getattr(sync, "network_id", None) if reseau is not None: reseaux.add(reseau) etat["networks"] = len(reseaux) except Exception as erreur: # une panne réseau ne doit pas abattre « start » etat["error"] = f"{type(erreur).__name__}: {erreur}" return etat