Chiron (7/11) : connexion

8 views
Skip to first unread message

Laurent Caillette

unread,
Jan 31, 2018, 1:27:27 PM1/31/18
to tec...@googlegroups.com

Comment initie-t-on une connexion par WebSocket avec Chiron ? En gros, on démarre un connecteur (fourni) sur l'Upend, le Downend, qui s'envoient des objets ``Command``, ou émettent des notifications de changement d'état de la connexion. 


=== Interface programmatique

Regardons le ``CommandTransceiver``, celui qui fournit le plus de fonctionnalités. Il requiert les paramètres suivants :

- Une boucle d'événements Netty, partageable avec d'autres Downend ou même un Upend.
- L'URL à laquelle se connecter.
- L'adresse du proxy HTTP, en option.
- Du chiffrement TLS (plus précisément une fabrique de ``javax.net.ssl.SSLEngine``), en option.
- Une fonction de rappel pour l'authentification.
- Une fonction d'encodage-décodage des objets ``Command``.
- Un intercepteur d'objets ``Command`` pour bricoler (par exemple les dédoublonner), en option.
- Une fonction de rappel pour consommer les objets ``Command`` envoyés par l'Upend.
- Une fonction de rappel pour indiquer l'état général de la connexion, en option.

Le ``CommandTransceiver`` se démarre et s'arrête avec des méthodes ``start()`` et ``stop()``, toutes les deux asynchrones, et qui renvoient une ``CompletableFuture`` (ce qui permet d'avoir un appel bloquant avec un ``join()`` si on veut).

Il y a bien sûr une méthode ``send(_)`` pour envoyer des objets ``Command``.

Le type générique des ``Command`` montantes et descendantes est établi par les paramètres de type du ``CommandTransceiver`` (``UPWARD_DUTY`` et ``DOWNWARD_DUTY``).


=== État général

Les notifications de changement d'état général du ``CommandTransceiver`` indiquent :
- Si une tentative d'autentification vient d'échouer.
- Quand on est connecté.
- Quand on est authentifié.
- S'il y a des commandes en vol.

Lorsque la connexion s'effectue, le ``CommandTransceiver`` transmet un descripteur de connexion indiquant la version de l'Upend, et si la connexion est authentifiée.

Concernant les commandes en vol, on reçoit des notifications dans les cas suivants :
- Au moins une ``Command`` émise n'a pas encore reçu de réponse alors qu'elle en attend une.
- Toutes les ``Command`` qui attendaient une réponse en ont reçu une.
- Une ``Command`` a généré une erreur côté Upend (c'est le ``Tracker`` qui dira laquelle).

Ainsi on peut câbler un indicateur visuel qui informe de l'activité en cours.


=== Retour d'erreur

Tous les retours d'erreur s'effectuent par le ``Tracker`` (déjà présenté) qui associe des fonctions de rappel à l'état d'une ``Command``. L'envoi d'une ``Command`` ne provoque //jamais// d'erreur au moment de l'appel, même si le connecteur n'est pas démarré. Pourquoi ? Parce que la plupart des erreurs intéressantes ne sont pas détectables au moment de l'appel, elles apparaissent dans un autre fil d'exécution. Donc pas la peine de polluer l'interface programmatique avec des exceptions qui ne servent à rien. Envoyer des ``Command`` avec un ``CommandTransceiver`` qui n'est pas démarré, c'est un comportement qui se corrige dans l'interface graphique.


=== Reconnexion automatique

Le ``CommandTransceiver`` envoie régulièrement des "ping" WebSocket, selon un intervalle défini. S'il ne reçoit pas de "pong", il se reconnecte automatiquement, et tente de réutiliser l'identifiant de session en cours. Si l'Upend estime que la session est invalide, le ``CommandTransceiver`` relance alors une authentification complète. 



=== Authentification (Upend)

Chiron fournit une solution complète d'authentification par mot de passe, avec en option un deuxième facteur d'authentification. Attention ça va devenir technique.

Voici la modélisation la plus triviale pour un service d'authentification par mot de passe :

<<<
/** Pas bon. */
interface Authenticator {
  boolean authenticate( String login, String password ) ;
}
>>>

Ça fonctionne si :
- On n'a pas peur de bloquer sur l'appel.
- On n'a pas besoin d'authentification à deux facteurs.
- L'utilisateur a droit à un nombre de tentatives illimitées, ou alors il n'a pas de retour d'erreur s'il explose le compteur de tentatives échouées.

Autrement dit ça ne fonctionne pas, ça fait juste semblant. 

Considérons le cas suivant : chaque tentative d'authentification est journalisée. Un attaquant lance une attaque par la force brute. Si on journalise chaque tentative, l'attaquant peut faire exploser le journal ; autrement dit une approche naïve déroule le tapis rouge au déni de service. Comment contenir les dégâts ? 

Tout d'abord il faut préciser quelques aspects architecturaux. Chiron entame le procès d'authentification dès que la connexion réseau a eu lieu, c'est à dire dans le ``ChannelPipeline`` créé par Netty. Chiron définit une interface ``SessionSupervisor`` avec le ``DefaultSessionSupervisor`` comme implantation par défaut. Le ``DefaultSessionSupervisor`` crée les sessions mais n'a pas de connaissance directe des utilisateurs. Il va requêter de façon asynchrone la logique applicative, en passant par l'interface ``SignonInwardDuty``. Cette dernière définit un contrat asynchrone. Les méthodes de ``SignonInwardDuty`` ne renvoient rien ; pour fournir un résultat la logique applicative appelle des méthodes de ``SignonOutwardDuty`` (qui définit lui aussi un contrat asynchrone, comme on l'avait probablement deviné).

L'objet passé au ``SessionSupervisor`` pour représenter un utilisateur doit fournir un authentifiant de connexion (``login``) et un numéro de téléphone pour l'authentification à deux facteurs, mais ce n'est pas bien méchant.

Après qu'est-ce qu'on a dans ``SignonInwardDuty`` ? Des trucs de ce genre :

<<<
public interface SignonInwardDuty {

  void primarySignonAttempt(
      Designator designator,
      String login,
      String password
  ) ;
  
  void failedSignonAttempt(
      Designator designatorInternal,
      String login,
      SignonAttempt signonAttempt
  ) ;

  void registerSession(
      Designator designatorInternal,
      SessionIdentifier sessionIdentifier,
      String login
  ) ;
  
  // ...
}  
>>>

Et dans ``SignonOutwardDuty`` :

<<<
public interface SignonOutwardDuty {

  void primarySignonAttempted(
      Designator designatorInternal,
      SignonDecision< SignableUser > signonDecision
  ) ;

  void sessionCreated(
      Designator designatorInternal,
      SessionIdentifier sessionIdentifier,
      String login
  ) ;

  void sessionCreationFailed(
      Designator designatorInternal,
      SessionIdentifier sessionIdentifier,
      SignonFailureNotice signonFailureNotice
  ) ;
  
  // ...
}
>>>

L'astuce pour déjouer le déni de service par saturation du journal, c'est de d'abord demander si l'utilisateur peut se connecter avec un appel à ``SignonInwardDuty#primarySignonAttempt``, qui n'est pas journalisé. Si la logique applicative est d'accord, elle répond en appelant ``SignonOutwardDuty#primarySignonAttempted`` avec un objet ``SignonDecision`` qui contient l'identité de l'utilisateur (avec son `n°` de téléphone), ou un descripteur d'erreur. Là, le ``DefaultSessionSupervisor`` déclenchera l'authentification par un second facteur. On saute les détails et on dit que finalement l'utilisateur a entré le bon code. Le ``DefaultSessionSupervisor`` considère la session comme valide de son côté. Il appelle alors ``SignonInwardDuty#registerSession`` qui est journalisé. La logique applicative vérifie que l'utilisateur a toujours le droit d'ouvrir une session (il a pu se passer des choses entre temps), et appelle finalement ``SignonOutwardDuty#sessionCreated``. Le ``SessionSupervisor`` peut alors envoyer la réponse indiquant que la connexion est authentifiée.

Si l'application doit rejouer le journal, seule les créations de session sont rejouées, ou alors l'incrémentation du compteur d'échec de tentatives, mais pas les tentatives d'authentification pouvant déboucher sur un échec. Pour les détails on peut se rapporter à la Javadoc du "``DefaultSessionSupervisor``"

Oui c'est plus poilu que la pauvre méthode ``authenticate( login, password )`` qui renvoie vrai ou faux. Mais si on veut entremêler les entrées-sorties requises par le deuxième facteur d'authentification tout en conservant des appels non-bloquants il y a un prix à payer. 

Nous remarquerons que la logique applicative a une connaissance fine de la session créée ; c'est le même objet ``SessionIdentifier`` qui est véhiculé par le ``Designator`` qu'on retrouve dans chaque méthode exposée par la logique applicative. Chiron fournit un objet ``SessionStore`` pour aider la logique applicative à conserver l'association entre utilisateur et session. Cela permet notamment de pousser des notifications vers tous les utilisateurs connectés, ou d'indiquer l'état de connexion d'un autre utilisateur si on souhaite des fonctionnalité de mise en contact en temps réel. 

Pour l'instant Chiron interdit qu'un utilisateur ouvre plus d'une session à la fois.


=== Deuxième facteur d'authentification

Pour activer l'authentification à deux facteurs, il suffit de fournir une instance de ``SecondaryAuthenticator`` au constructeur de l'``UpendLogic``. Chiron fournit par défaut le ``TwilioSecondaryAuthenticator``, qui passe un appel téléphonique au numéro associé avec l'utilisateur en cours d'authentification, et lui murmure un code à six chiffres d'une voix suave. Évidemment il faut créer préalablement un compte "Twilio" 
et fournir les bons paramètres d'authentification à Chiron. Pourquoi Twilio plutôt qu'un autre ? Parce que c'est la solution de phonie fournie en standard par Google AppEngine, et que les ingénieurs de Google ont plus de temps que moi pour évaluer ce genre de produit, qui généralement ne révèle ses faiblesses qu'à une phase avancée de l'intégration.


=== Authentification (Downend)

Comment se passe l'authentication du côté de l'utilisateur ? Chiron définit une interface ``SignonMaterializer`` modélisant l'interaction avec l'utilisateur. Évidemment tous les appels de méthodes sont asynchrones, si on attend un résultat on passe une fonction de rappel :

<<<
public interface SignonMaterializer {
  void readCredential( Consumer< Credential > credentialConsumer ) ;
  void readSecondaryCode( Consumer< SecondaryCode > secondaryCodeConsumer ) ;
  void waitForCancellation( final Runnable afterCancelled ) ;
  void setProgressMessage( String message ) ;
  void setProblemMessage( SignonFailureNotice signonFailureNotice ) ;
  void done() ;
}
>>>

L'implantation typique d'un ``SignonMaterializer`` est une fenêtre de dialogue. Un appel aux méthodes ``read*(_)`` cause son apparition, un appel à ``done()`` cause sa fermeture, et suite à un appel à ``waitForCancellation(_)`` elle doit attendre que l'utilisateur clique sur "Annuler".

Il existe quelque part une implantation pour Swing mais elle n'est pas fournie avec Chiron.


=== Produits concurrents

À part le truc assez râpeux fourni par `Java EE`, je ne connais que "Apache Shiro"
en tant que socle technique d'authentification. L'un et l'autre n'offrent que des primitives bloquantes, et autant que je sache, ils ne permettent pas au code applicatif de voir toutes les sessions ouvertes. Ce qui est normal puisque ces produits peuvent être déployés dans une grappe de serveurs où l'on s'efforce de décentraliser la notion de session. 

Sinon, j'ai beau googler "Shiro 2FA" je ne trouve rien. Donc pas d'authentification à deux facteurs pour Shiro.


=== Mais c'est trop compliqué !

L'authentification est la partie la plus compliquée de Chiron. Oui ça doit faire peur, parce que c'est plus facile de laisser des erreurs dans du code compliqué. La difficulté c'est de trouver la complexité minimale pour un sujet donné. À l'heure actuelle Chiron est probablement le seul socle technique en Java fournissant de l'authentification à deux facteurs //et// des primitives non-bloquantes //et// la compatibilité avec une persistance journalisée. Quand un produit introduit de nouveaux concepts il a forcément l'air plus compliqué. 

Une façon de diminuer le risque, c'est d'écrire beaucoup de tests et d'écrire du code très défensif.  Par exemple, l'identité de l'utilisateur est vérifiée à chaque étape d'authentification ainsi que l'adresse IP de l'initiateur de la connexion. Pour voler une session, il faut non seulement obtenir l'identifiant de session transmis par HTTPS, mais également utiliser la même adresse IP que l'initiateur de la connexion. 

Reply all
Reply to author
Forward
0 new messages