Canalblog
Editer l'article Suivre ce blog Administration + Créer mon blog
Publicité
Fanfois et ses papillons
8 avril 2012

Aspirateur 16 (authentification 1ère partie)

Avec ce billet nous attaquons la dernière ligne droite de la réalisation de notre aspirateur, à savoir l'authentification.

Il s'agit bien d'une ligne droite, même d'une longue ligne droite. Probablement de trois billets, car il y a beaucoup de choses à faire. Déjà il faut se mettre d'accord sur ce que l'on appelle authentification. Il y a en effet plusieurs méthodes pour gérer l'authentification sur un site web. Certaines sont liées à l'OS, d'autres au réseau. Cela dit lorsque l'on parle d'authentification pour un site internet, donc sans limitations vis à vis de la nature du poste client et du réseau mis en œuvre, on parle généralement d'authentification par formulaire. C'est cette dernière que nous allons adresser dans notre aspirateur. Du coup il serait bon de savoir comment fonctionne ce mode d'authentification.
Le site possède une page dynamique particulière dédiée à l'authentification. Cette page est généralement incluse dans toutes les pages du site (via la master page en ASP.NET) pour permettre à l'utilisateur de s'authentifier n'importe où dans le site. Pour l'utilisateur elle peut être toujours visible dans un coin des pages, soit n'être visible que sur demande dans une popup. La raison d'être de cette page, fournir un moyen à l'utilisateur de donner les éléments nécessaires à son authentification. Dans la très vaste majorité des sites internet (par opposition à intranet), il s'agit de deux champs textuels, "login" et "password". On parle d'authentification formulaire parce que la page d'authentification est disponible via un tag HTML form, un formulaire donc, inséré dans les autres pages du site.
Le traitement, coté serveur, de cette page consiste à valider les éléments fournis par l'utilisateur. Comment le serveur fait-il ? Ce n'est pas notre problème, mais généralement il consulte une base de données. Si le serveur est content, il le fait savoir en retournant des cookies qui vont lui permettre de gérer une pseudo session (pseudo session, car une vraie session se gère au niveau du réseau). Dans la très vaste majorité des cas, le retour de l'authentification consistera en une redirection vers la page où se trouvait l'utilisateur lorsqu'il a utilisé le formulaire. Le but de cette redirection, réafficher la page en mode authentifié. Du point de vue de l'aspirateur, cela signifie qu'il conviendra de gérer les redirections, au moins dans ce cas. En effet la page affichée pourrait être différente lorsque l'on est authentifié et c'est cette version que nous souhaitons conserver localement.
Pour le reste de la session (pseudo session étant assez barbant, nous parlerons de session), le client devra fournir les cookies pour montrer qu'il est bien authentifié. S'il vient à manquer un cookie, le serveur ne sera pas satisfait et "fermera" la session.
Généralement l'utilisateur peut mettre fin à sa session via un bouton ou un lien. Notre aspirateur suivant tous les liens qu'il rencontre dans une page, il faudra probablement limiter ces ardeurs pour éviter qu'il ne mette "bêtement" fin à la session en suivant le lien dédié à cet effet.

Si nous voulons que l'aspirateur réussisse à passer avec succès ce mode d'authentification, nous devons lui apprendre à faire pas mal de choses :

  • trouver le formulaire d'authentification dans la page. Le faire de manière entièrement automatique, et à coup sûr, dans tous les cas n'est peut-être pas impossible, mais est tout de même au-delà de ce qui est envisagé dans cette série de billets. Nous allons imposer le fait que l'authentification puisse se faire depuis la page initiale de l'aspiration. Comme une page peut parfaitement posséder plusieurs formulaires, l'utilisateur va être mis à contribution. C'est lui qui va dire quel est le bon formulaire. L'aspirateur va tout de même l'aider, en recherchant pour lui les tags formprésents dans la page. Mine de rien nous devons donc :
    • savoir trouver les formulaires, et leurs éléments constitutifs, dans une page ;
    • construire une nouvelle IHM qui permette à l'utilisateur d'indiquer quel est le bon formulaire ;
    • définir de nouveaux objets dans le Model pour stocker les informations liées aux formulaires ;
    • définir les objets correspondants dans les autres couches, à savoir ViewModel et View mais également service XML de sérialisation ;
  • utiliser les informations liées à un formulaire pour l'envoyer au serveur ;
  • gérer les cookies lors des échanges avec le serveur ;
  • détecter si l'authentification est toujours active ;
  • ne pas suivre certaines URL.

Même si cette liste n'est pas totalement exhaustive, tous les points notables y sont référencés.

Notons au passage que le dernier point va devenir très important lorsque nous serons capables de nous authentifier. En effet, s'il sera évidemment nécessaire de ne pas suivre le ou les liens permettant de mettre fin à la "session", plein d'URL proposées par le site ne devront probablement pas être aspirées.
Imaginez le cas d'un site traitant d'un sujet qui vous passionne. Vous souhaitez récupérer les "articles" proposés par le dit site. Pour cela il faut être authentifié, ce qui ne sera plus un problème d'ici peu. Une fois authentifié le site propose, par exemple, un forum. Aspirer le forum n'est pas forcément une bonne idée. Déjà une partie, malheureusement importante, du forum peut être constituée de contenu très moyen (euphémisme). Même si l'aspect "étude du néant" peut vous intéresser, la quantité de billets peut poser un problème, de place disque, mais surtout de temps d'aspiration. Aspirer 10 ans de discussions sur un forum ayant 1000 abonnés actifs, ça peut faire très très mal.

Revenons plus en détail sur ce qu'implique "trouver les formulaires" et "permettre à l'utilisateur de choisir le bon".
Sans surprise rechercher les tags HTML form dans la page initiale. Mais pas que. Il faut aussi analyser le contenu des tags form trouvés et montrer à l'utilisateur ce que l'aspirateur va en faire.
L'idée est certes d'aider l'utilisateur dans sa décision, mais également de permettre à un utilisateur avancé d'anticiper ce qui va se passer. Si l'aspirateur interprète mal le HTML, la chose sera visible et il sera possible de ne pas tenter une authentification, dès lors mal engagée.
Il est en effet possible que notre aspirateur interprète mal le HTML. En effet il n'est pas rare de trouver du HTML mal formé. Les butineurs ont derrière eux plus de 10 ans de magouilles, plus ou moins tordues, pour s'en sortir. Inutile de préciser que ce ne sera pas le cas de notre aspirateur, qui pourra donc très mal s'en sortir. Dans ce cas, un utilisateur avancé pourra remettre les choses en ordre. Comment ? En modifiant à la main le fichier de configuration de l'aspirateur. Notons que dans ce cas, on ne parle plus d'utilisateur avancé, mais de consultant (dument tarifé, cela va de soi).

Avant de nous occuper, longuement, de l'IHM, commençons par le travail en soute. Tant qu'à faire, commençons par le début, à savoir le traitement du HTML de la page initiale.
Première étape, définir une expression régulière qui traite les tags form. A la différence de ce que nous avons fait jusqu'ici, le HTML se trouvant entre les balises ouvrante et fermante nous intéresse. En effet nous devrons utiliser une seconde expression régulière qui analyse le HTML se trouvant entre les deux balises du formulaire pour trouver les champs devant être envoyés vers le serveur web.
Si vous avez du mal avec ce qu'il convient de faire, regardez ce qu'en dit le W3C. Je vous rassure, nous n'allons pas tout traiter. Nous allons même nous limiter au tag input.
Est-ce une limitation trop importante ?
Généralement, non. Le plus important, pour nous, c'est la saisie de texte. La saisie de texte peut également être faite avec des tags textarea. Cela dit les textarea ne sont normalement utilisés que pour la saisie de textes sur plusieurs lignes. Pour une identification, c'est peu probable.

Pour illustrer ce qui doit être fait, nous allons prendre comme exemple le HTML correspondant à l'authentification se trouvant sur la page d'accueil de CanalBlog :

<div id="signin_menu">
<form id="loginform" name="loginform" action="/cf/security/SessionController.cfc?method=login" method="post" enctype="application/x-www-form-urlencoded">
<div style="width:160px; margin:0 auto;">
<div class="label">* Identifiant</div>
<input type="text" id="memberid" name="memberid" maxlength="16" />
<div class="label">* Mot de passe</div>
<input name="password" maxlength="16" type="password" />
<br/>
<span><a href="/cf/forgetPassword.cfm" style="font-style:italic; font-size:10px; color:white;">Mot de passe oublié ?</a></span>
<a style='background: url("/sharedDocs/images/btn-02.png") no-repeat; color: #FFFFFF; cursor: pointer; float: right; font-size: 13px; line-height: 20px; padding: 0 25px 0 0; text-decoration: none;' onClick="document.forms['loginform'].submit();">
<span style='float: left; padding: 3px 25px 3px 26px; color:white;'>Se connecter</span>
</a>
<input type="hidden" name="errorReturnTo" value="/cf/login.cfm" />
<input type="hidden" name="returnTo" value="" />
<input type="hidden" name="erroron" value="" />
<button type="submit" style="visibility:hidden;">submit</button>
</div>
</form>
</div>

Le formulaire a un identifiant (id="loginform") et un nom (name="loginform"). C'est sans intérêt vis à vis du serveur web, mais vital pour le code de la page (typiquement pour le onClick) et probablement utile pour aider l'utilisateur à déterminer quel est le bon formulaire. A ce sujet, les plus audacieux pourront tenter de mettre en place une détection automatique fondée sur le nom du formulaire. Il est en effet assez courant de trouver "login", "connexion" ou "connection" dans le nom de ce formulaire. Bon les grincheux feront remarquer qu'il est encore plus courant de trouver des formulaires sans nom.
Le plus important du point de vue du dialogue avec le serveur web, ce sont les attributs définissant la cible (action="/cf/security/SessionController.cfc?method=login"), le mode d'appel (method="post") et l'encodage (enctype="application/x-www-form-urlencoded"). Au niveau du tag, seule la cible est obligatoire. Les deux autres sont optionnelles. Par défaut le mode d'appel est get. La seule autre valeur possible est post. En ce qui concerne l'encodage, application/x-www-form-urlencoded et la valeur par défaut. L'autre valeur possible est multipart/form-data. Compte tenu de son usage, nous ne traiterons pas ce second mode d'encodage (il est destiné aux fichiers et franchement je doute que l'authentification nécessite l'envoi de fichiers).
L'expression régulière devra également remonter le HTML constituant le corps du formulaire pour que nous y recherchions les tags input. Comme nous l'avons dit, ils constituent les éléments du formulaire qu'il nous faudra transmettre au serveur. Nous devrons donc définir une seconde expression régulière qui s'occupe des tags input. Cela dit nous ne traiterons réellement que les tags inputayant les types suivants :

  • text
  • password
  • hidden
  • submit

Le type hidden indique que l'utilisateur ne voit pas le champ et ne peut donc pas le modifier. Cela signifie généralement que nous devrons transmettre le champ tel quel. Evidemment, si la page contient du code script qui remplit certains champs cachés, l'aspirateur ne saura pas les remplir. Cela dit, l'aspirateur permettra à un utilisateur avancé de les remplir à la main, soit via l'IHM, soit directement dans le fichier XML de configuration.
Un formulaire peut contenir plusieurs input de type submit. Seul celui utilisé par l'utilisateur doit être pris en compte pour la requête envoyée au serveur. Il nous faudra donc permettre à l'utilisateur de choisir le bon submit si plusieurs venaient à être présents dans le formulaire.
L'expression régulière devra détecter la présence de l'attribut disabled. En effet, cet attribut change complètement l'intérêt que nous pouvons porter au champ. Un input disabled ne doit pas être transmis au serveur.
Le couple d'attributs name / value constitue évidemment une cible prioritaire pour notre seconde expression régulière.
Certains peuvent s'étonner du fait que le traitement des input de type checkbox ne soit pas mis en place. Il est vrai que de nombreux sites utilisent une case à cocher pour permettre de mettre en place, ou pas, une connexion automatique. La connexion automatique repose sur des cookies persistants. Persister un cookie ne représente pas de difficulté technique, nous pouvons très facilement le persister dans le fichier XML. Cependant ce cookie ne sera connu que de l'aspirateur et de ce fait très probablement invalide lors de la prochaine aspiration. En effet, entre temps, une navigation normale l'aura invalidé. Si l'on tient compte, en plus, du fait que les checkbox non cochées ne doivent pas être émise vers le serveur, ne pas gérer les input de type checkbox ne devrait pas poser de problème.

Commençons par l'expression régulière s'occupant des tags form. Etendre une des expressions régulières que nous avons déjà définie pour l'adapter au cas du form ne présente pas vraiment de difficultés (pour faciliter la lecture, l'expression est présentée sur plusieurs lignes) :

<\s*form\b(?>\s+(?:
  action\s*=\s*(?(["'])(["'])(?<action>.*?(?=\1))\1|(?<action>[^>\s]*))()|
  method\s*=\s*(?(["'])(["'])(?<method>.*?(?=\3))\3|(?<method>[^>\s]*))()|
  enctype\s*=\s*(?(["'])(["'])(?<enctype>.*?(?=\5))\5|(?<enctype>[^>\s]*))()|
  id\s*=\s*(?(["'])(["'])(?<id>.*?(?=\7))\7|(?<id>[^>\s]*))()|
  name\s*=\s*(?(["'])(["'])(?<name>.*?(?=\9))\9|(?<name>[^>\s]*))()
)|
[^\s>]+|\s+?)*\2>
(?<html>.*?)
</\s*form\s*>

Le groupe de capture "html" nous permet de récupérer le corps du formulaire. C'est sur ce HTML que nous devrons passer la seconde expression régulière, celle qui traite les input. Par rapport à ce que nous avons déjà rencontré il y a une difficulté, l'attribut disabled. En effet il n'a pas de valeur associé. S'il est présent, c'est qu'il est positionné à vrai. Cela nous conduit à l'expression régulière suivante (également présentée sur plusieurs lignes) :

<\s*input\b(?>\s+(?:
  type\s*=\s*(?(["'])(["'])(?<type>.*?(?=\1))\1|(?<type>[^>\s]*))()|
  name\s*=\s*(?(["'])(["'])(?<name>.*?(?=\3))\3|(?<name>[^>\s]*))()|
  value\s*=\s*(?(["'])(["'])(?<value>.*?(?=\5))\5|(?<value>[^>\s]*))()|
  (?<disabled>disabled\b)
)|
[^\s>]+|\s+?)*\4>

Les plus perspicaces auront remarqués que l'attribut type n'est pas obligatoire avec notre expression régulière. C'est normal, la valeur par défaut est "text".
Toujours pour les lecteurs perspicaces, l'attribut name a été rendu obligatoire dans l'expression régulière. Ce n'est pas requis par le tag input, mais par l'utilisation qui en est faite. En effet ne sont pris en compte dans un formulaire, pour la requête transmise au serveur, que les input ayant un nom.
Maintenant que nous avons nos expressions régulières, nous sommes en mesure de récupérer les formulaires présents dans une page. Il nous faut donc de quoi stocker ces informations, nous devons donc étendre notre Model. Nous allons ajouter deux classes au Model, une pour les formulaires et une pour les champs de formulaire. Nous n'allons pas spécialiser ces classes, nous allons faire des classes susceptibles de convenir pour tout type de formulaire et pas juste pour des formulaires d'authentification. Nous ajoutons donc au répertoire Models du projet Aspirateur.Model les éléments suivants (les fichiers ont le même nom que les classes qu'ils contiennent) :

using System.ComponentModel;
 
namespace Aspirateur.Model
{
    public enum HttpMethod : int
    {
        [Description("GET")]
        GET = 0,
        [Description("POST")]
        POST = 1,
    }
}
using System.ComponentModel;
 
namespace Aspirateur.Model
{
    public enum HttpEncoding : int
    {
        [Description("application/x-www-form-urlencoded")]
        URLENCODED = 0,
        [Description("multipart/form-data")]
        MULTIPART = 1,
    }
}
using System.ComponentModel;
 
namespace Aspirateur.Model
{
    public enum FieldType : int
    {
        [Description("Text")]
        TEXT = 0,
        [Description("Password")]
        PASSWORD = 1,
        [Description("Hidden")]
        HIDDEN = 2,
        [Description("Submit")]
        SUBMIT = 3,
    }
}
using System;
using System.ComponentModel.DataAnnotations;
using MVVM.Framework.Model;
 
namespace Aspirateur.Model
{
    /// <summary>
    /// Données d'un champ appartenant à un formulaire.
    /// </summary>
    public class FormFieldModele : ModeleBase
    {
        #region Membres
        // Pour les propriétés.
        private FieldType _type = FieldType.TEXT;
        private string _name = null;
        private string _content = null;
        #endregion Membres
 
        #region Constructeurs
        /// <summary>
        /// Il nous faut un constructeur par défaut, mais il ne devrait servir que pour la sérialisation.
        /// </summary>
        public FormFieldModele() : base() { }
 
        /// <summary>
        /// Le constructeur complet.
        /// </summary>
        /// <param name="type"></param>
        /// <param name="name"></param>
        /// <param name="content"></param>
        public FormFieldModele(FieldType type, string name, string content)
            : base()
        {
            _type = type;
            _name = name;
            _content = content;
        }
        #endregion Constructeurs
 
        #region Type
        /// <summary>
        /// La nature du champ du formulaire.
        /// </summary>
        public FieldType Type
        {
            get { return _type; }
            set
            {
                if (_type != value)
                {
                    RaisePropertyChanging<FieldType>(() => Type);
                    _type = value;
                    RaisePropertyChanged<FieldType>(() => Type);
                }
            }
        }
        #endregion Type
 
        #region Name
        /// <summary>
        /// Le nom du champ du formulaire.
        /// </summary>
        [Required(ErrorMessage = "Chaque champ doit avoir un nom.")]
        public string Name
        {
            get { return _name; }
            set
            {
                if (_name != value)
                {
                    RaisePropertyChanging<string>(() => Name);
                    _name = value;
                    RaisePropertyChanged<string>(() => Name);
                }
            }
        }
        #endregion Name
 
        #region Content
        /// <summary>
        /// La valeur associée au champ du formulaire (value étant un mot=clé...).
        /// </summary>
        public string Content
        {
            get { return _content; }
            set
            {
                if (_content != value)
                {
                    RaisePropertyChanging<string>(() => Content);
                    _content = value;
                    RaisePropertyChanged<string>(() => Content);
                }
            }
        }
        #endregion Content
    }
}
using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using MVVM.Framework.Model;
 
namespace Aspirateur.Model
{
    /// <summary>
    /// Données décrivant un formulaire.
    /// Les noms des propriétés correspondent à priori aux attributs du tag HTML FORM.
    /// </summary>
    public class FormModele : ModeleBase
    {
        #region Membres
        // Pour les propriétés.
        private string _name = null;
        private string _action = null;
        private HttpMethod _method = HttpMethod.GET;
        private HttpEncoding _encoding = HttpEncoding.URLENCODED;
        private List<FormFieldModele> _fieldList = new List<FormFieldModele>();
        #endregion Membres
 
        #region Constructeurs
        /// <summary>
        /// Il nous faut un constructeur par défaut, mais il ne devrait servir que pour la sérialisation.
        /// </summary>
        public FormModele() : base() { }
 
        /// <summary>
        /// Le constructeur complet.
        /// </summary>
        /// <param name="name"></param>
        /// <param name="action"></param>
        /// <param name="method"></param>
        /// <param name="encoding"></param>
        public FormModele(string name, string action, HttpMethod method = HttpMethod.GET, HttpEncoding encoding = HttpEncoding.URLENCODED)
            : base()
        {
            _name = name;
            _action = action;
            _method = method;
            _encoding = encoding;
        }
        #endregion Constructeurs
 
        #region Name
        /// <summary>
        /// Le nom du formulaire.
        /// </summary>
        public string Name
        {
            get { return _name; }
            set
            {
                if (_name != value)
                {
                    RaisePropertyChanging<string>(() => Name);
                    _name = value;
                    RaisePropertyChanged<string>(() => Name);
                }
            }
        }
        #endregion Name
 
        #region Action
        /// <summary>
        /// L'action associée au formulaire.
        /// </summary>
        [Required(ErrorMessage = "Un formulaire doit avoir une action associée.")]
        public string Action
        {
            get { return _action; }
            set
            {
                if (_action != value)
                {
                    RaisePropertyChanging<string>(() => Action);
                    _action = value;
                    RaisePropertyChanged<string>(() => Action);
                }
            }
        }
        #endregion Action
 
        #region Method
        /// <summary>
        /// Le mode d'envoi HTTP du formulaire.
        /// </summary>
        public HttpMethod Method
        {
            get { return _method; }
            set
            {
                if (_method != value)
                {
                    RaisePropertyChanging<HttpMethod>(() => Method);
                    _method = value;
                    RaisePropertyChanged<HttpMethod>(() => Method);
                }
            }
        }
        #endregion Method
 
        #region Encoding
        /// <summary>
        /// L'encodage HTTP devant être utilisé pour envoyer le formulaire.
        /// </summary>
        public HttpEncoding Encoding
        {
            get { return _encoding; }
            set
            {
                if (_encoding != value)
                {
                    RaisePropertyChanging<HttpEncoding>(() => Encoding);
                    _encoding = value;
                    RaisePropertyChanged<HttpEncoding>(() => Encoding);
                }
            }
        }
        #endregion Encoding
 
        #region FieldList
        /// <summary>
        /// La liste des champs du formulaire.
        /// </summary>
        public List<FormFieldModele> FieldList
        {
            get { return _fieldList; }
            set
            {
                if (_fieldList != value)
                {
                    RaisePropertyChanging<List<FormFieldModele>>(() => FieldList);
                    _fieldList = value;
                    RaisePropertyChanged<List<FormFieldModele>>(() => FieldList);
                }
            }
        }
        #endregion FieldList
    }
}

Il nous faut maintenant écrire tout le code permettant de sauver les nouveaux éléments du Model dans le fichier XML. Nous avons déjà fait cela deux fois, et il n'y a rien de nouveau dans ces classes. Pour limiter la taille de ce billet je vous invite a regarder comment cela se fait dans les précédents billets. Pour vous aider, voici la liste des classes qu'il faut ajouter :

  • répertoire Services du projet Aspirateur.Model, interfaces IFormFieldsService et IFormsServices ;
  • projet Aspirateur.ModelGlobal, interfaces IForm et IFormField. Ne pas oublier les implémentations Form et FormField. Pour mettre un peu d'ordre dans ce projet, deux répertoires y ont été mis en place dans le code que vous trouverez à la fin de ce billet. Il s'agit de Implementations et Interfaces ;
  • projet Aspirateur.ServicesXML, classes FormFieldsService et FormsService. Ne pas oublier que ces classes ont leur code réparti sur deux fichiers chacune. Dans le répertoire Mappers, les classes FormFieldMapper et FormMapper ;
  • ne pas oublier d'ajouter à la méthode EnregistrerServices de la classe App du projet Aspirateur.Main les deux initialisations nécessaires.

Vous trouverez dans le rar fourni à la fin de ce billet une façon d'écrire ce code. Cela dit, écrivez le vous-même. Vous aurez probablement des problèmes ici ou là, mais c'est indirectement grâce à ces problèmes que vous progresserez, car vous les résoudrez.

Reste à lier ces nouvelles données à notre existant. Pour cela nous modifions la classe WebSiteModele en ajoutant deux propriétés. Comme les données concernées doivent être sérialisée, il faut également modifier toute la chaîne permettant la sérialisation (à vous de jouer). Le code ajouté au niveau de WebSiteModele est le suivant :

#region Membres
// Pour l'authentification.
private bool _authenticate = false;
private FormModele _authentForm = null;
#endregion Membres
 
#region Propriétés
// Propriétés liées à l'authentification.
#region Authenticate
/// <summary>
/// Activation de l'authentification lors de l'aspiration.
/// </summary>
public bool Authenticate
{
    get { return _authenticate; }
    set
    {
        if (_authenticate != value)
        {
            RaisePropertyChanging<bool>(() => Authenticate);
            _authenticate = value;
            RaisePropertyChanged<bool>(() => Authenticate);
        }
    }
}
#endregion Authenticate
 
#region AuthenticationForm
/// <summary>
/// Le formulaire d'authentification.
/// </summary>
public FormModele AuthenticationForm
{
    get { return _authentForm; }
    set
    {
        if (_authentForm != value)
        {
            RaisePropertyChanging<FormModele>(() => AuthenticationForm);
            _authentForm = value;
            RaisePropertyChanged<FormModele>(() => AuthenticationForm);
        }
    }
}
#endregion AuthenticationForm
#endregion Propriétés

A ce stade tout est prêt pour accueillir le formulaire mais il n'y a aucun code permettant de l'afficher et encore moins de récupérer ses données. Comme nous avons déjà présenté les deux expressions régulières, autant finir la partie recherche des formulaires dans le HTML d'une page. Nous ajoutons donc dans la partie métier d'Aspirateur.Model une nouvelle classe FormProcessor :

using System;
using System.Collections.Generic;
using System.Text;
using System.Text.RegularExpressions;
 
namespace Aspirateur.Model.Business
{
    class FormProcessor
    {
        #region Membres
        // L'expression régulière recherchant les formulaires (tag HTML form).
        private static Regex _formRegex = new Regex("<\\s*form\\b(?>\\s+(?:" +
                                                "action\\s*=\\s*(?([\"'])([\"'])(?<action>.*?(?=\\1))\\1|(?<action>[^>\\s]*))()|" +
                                                "method\\s*=\\s*(?([\"'])([\"'])(?<method>.*?(?=\\3))\\3|(?<method>[^>\\s]*))()|" +
                                                "enctype\\s*=\\s*(?([\"'])([\"'])(?<enctype>.*?(?=\\5))\\5|(?<enctype>[^>\\s]*))()|" +
                                                "id\\s*=\\s*(?([\"'])([\"'])(?<id>.*?(?=\\7))\\7|(?<id>[^>\\s]*))()|" +
                                                "name\\s*=\\s*(?([\"'])([\"'])(?<name>.*?(?=\\9))\\9|(?<name>[^>\\s]*))()" +
                                                ")|[^\\s>]+|\\s+?)*\\2>(?<html>.*?)</\\s*form\\s*>",
                                                RegexOptions.IgnoreCase | RegexOptions.CultureInvariant |
                                                RegexOptions.Multiline | RegexOptions.Singleline | RegexOptions.Compiled);
 
        // L'expression régulière recherchant les champs d'un formulaire (tag HTML input).
        private static Regex _fieldRegex = new Regex("<\\s*input\\b(?>\\s+(?:" +
                                                "type\\s*=\\s*(?([\"'])([\"'])(?<type>.*?(?=\\1))\\1|(?<type>[^>\\s]*))()|" +
                                                "name\\s*=\\s*(?([\"'])([\"'])(?<name>.*?(?=\\3))\\3|(?<name>[^>\\s]*))()|" +
                                                "value\\s*=\\s*(?([\"'])([\"'])(?<value>.*?(?=\\5))\\5|(?<value>[^>\\s]*))()|" +
                                                "(?<disabled>disabled\\b)" +
                                                ")|[^\\s>]+|\\s+?)*\\4>",
                                                RegexOptions.IgnoreCase | RegexOptions.CultureInvariant |
                                                RegexOptions.Multiline | RegexOptions.Singleline | RegexOptions.Compiled);
 
        // L'instance à utiliser pour le traitement des URL.
        private UrlFileMatcher _urlFileMatcher;
        #endregion Membres
 
        #region Constructeurs
        public FormProcessor(UrlFileMatcher urlFileMatcher)
        {
            _urlFileMatcher = urlFileMatcher;
        }
        #endregion Constructeurs
 
        #region Méthodes publiques
        public Dictionary<string, FormModele> ProcessPage(string pageContent)
        {
            Dictionary<string, FormModele> formulaires = new Dictionary<string, FormModele>();
 
            // On recherche les formulaires.
            int indiceFormulaire = 0;
            foreach (Match mForm in _formRegex.Matches(pageContent))
            {
                // L'expression régulière nous assure uniquement de la présence de l'action.
                // On vérifie donc qu'il y a un HTML qui contient au moins deux champs.
                string html = mForm.Groups["html"].Value;
                if (string.IsNullOrWhiteSpace(html))
                {
                    // Un formulaire vide, on ne sait pas traiter.
                    continue;
                }
                // On construit la liste des champs.
                List<FormFieldModele> fieldList = new List<FormFieldModele>();
                foreach (Match mField in _fieldRegex.Matches(html))
                {
                    // L'expression régulière nous assure uniquement de la présence du nom.
                    if (mField.Groups["disabled"].Success)
                    {
                        // On ne s'occupe pas des champs désactivés.
                        continue;
                    }
 
                    // Est-ce un type de champ que l'on gère ?
                    bool onSort = false;
                    FieldType type = FieldType.TEXT;
                    if (mField.Groups["type"].Success)
                    {
                        switch (mField.Groups["type"].Value.ToLower())
                        {
                            case "text":
                                // C'est déjà bon.
                                break;
                            case "password":
                                type = FieldType.PASSWORD;
                                break;
                            case "hidden":
                                type = FieldType.HIDDEN;
                                break;
                            case "submit":
                                type = FieldType.SUBMIT;
                                break;
                            default:
                                onSort = true;
                                break;
                        }
                    }
                    if (onSort)
                    {
                        continue;
                    }
 
                    // Y a-t-il une valeur associée ?
                    string content = string.Empty;
                    if (mField.Groups["value"].Success)
                    {
                        content = mField.Groups["value"].Value;
                    }
 
                    // On ajoute à la liste des champs.
                    fieldList.Add(new FormFieldModele(type, mField.Groups["name"].Value, content));
                }
 
                // On ne continue que s'il y a au moins 2 champs.
                if (fieldList.Count < 2)
                {
                    continue;
                }
 
                indiceFormulaire++;
                string nom = "SanNom" + indiceFormulaire.ToString();
                if (mForm.Groups["name"].Success && !string.IsNullOrWhiteSpace(mForm.Groups["name"].Value))
                {
                    nom = mForm.Groups["name"].Value;
                }
                else if (mForm.Groups["id"] != null && !string.IsNullOrWhiteSpace(mForm.Groups["id"].Value))
                {
                    nom = mForm.Groups["id"].Value;
                }
 
                HttpMethod method = HttpMethod.GET;
                if (mForm.Groups["method"].Success && (mForm.Groups["method"].Value.ToLower() == "post"))
                {
                    method = HttpMethod.POST;
                }
 
                HttpEncoding encoding = HttpEncoding.URLENCODED;
                if (mForm.Groups["enctype"].Success && (mForm.Groups["enctype"].Value.ToLower() == "multipart/form-data"))
                {
                    // On ne sait pas traiter mais on prend tout de même.
                    encoding = HttpEncoding.MULTIPART;
                }
 
                // Pour se simplifier le future, on calcule le chemin complet du formulaire.
                Uri absUrl, relUrl;
                string path;
                bool pipo;
                if (_urlFileMatcher.ProcessUrl(mForm.Groups["action"].Value, false, out absUrl, out relUrl, out path, out pipo, out pipo))
                {
                    // Si on n'est pas capable de générer le chemin complet maintenant,
                    // on ne devrait pas pouvoir faire mieux plus tard.
                    FormModele form = new FormModele(nom, absUrl.ToString(), method, encoding);
                    form.FieldList = fieldList;
                    formulaires.Add(nom, form);
                }
            }
 
            if (formulaires.Count == 0)
            {
                formulaires = null;
            }
            return formulaires;
        }
        #endregion Méthodes publiques
    }
}

Maintenant que nous avons la liste des formulaires, il faudrait demander à l'utilisateur lequel est le formulaire d'authentification. Il faudrait, car nous le ferons dans un prochain billet. Pour finir ce billet, et valider que le code que nous avons écrit est fonctionnel, nous allons modifier légèrement l'IHM de notre aspirateur pour permettre à l'utilisateur d'indiquer qu'il souhaite qu'une authentification soit réalisée au début de l'aspiration. Dans ce cas, un bouton lui permettra de demander la recherche des formulaires contenu dans la page initiale. Comme nous n'allons pas permettre à l'utilisateur de choisir le bon formulaire, nous associerons arbitrairement le premier formulaire de la liste au site. Il faudra regarder dans le fichier XML pour voir les nouvelles données.
Comment ça, ce n'est pas pratique !
Ce n'est peut-être pas pratique mais le billet est déjà bien long. L'IHM c'est pour le prochain billet !

Au niveau du ViewModel du site web, nous avons besoin d'une nouvelle commande pour le bouton qui lancera la recherche des formulaires. Nous ajoutons donc le code suivant à WebSiteViewModel:

using System.Collections.Generic;
 
#region Proprietes
#region Commandes
#region ConfigurerAuthentCommand
public ProxyCommand<WebSiteViewModel> ConfigurerAuthentCommand { get; private set; }
#endregion ConfigurerAuthentCommand
#endregion Commandes
#endregion Proprietes
 
#region Fonctions surchargées
protected override void InitialisationDesCommandes()
{
    OuvrirFicheSiteCommand = new ProxyCommand<WebSiteViewModel>(_ => OuvrirFicheSite(), this);
    AspirerSiteCommand = new ProxyCommand<WebSiteViewModel>(_ => AspirerSiteAsync(), this);
    ConfigurerAuthentCommand = new ProxyCommand<WebSiteViewModel>(_ => ConfigurerAuthentification(), this);
}
#endregion Fonctions surchargées
 
#region Helper
/// <summary>
/// Recherche les formulaires présent dans la page initiale et 
/// permet à l'utilisateur de choisir celui correspondant à l'authentification.
/// </summary>
/// <returns></returns>
private void ConfigurerAuthentification()
{
    // On récupère la liste des formulaires de la page initiale.
    Dictionary<string, FormModele> formulaires;
    formulaires = _site.GetFormList();
 
    // On affiche la liste des formulaires pour que l'utilisateur en choisisse un.
    // En fait pas maintenant..
 
    // On associe ce formulaire au site courant.
    // Il faut en choisir un, on prend le premier de la liste.
    FormModele oldForm = Site.AuthenticationForm;
    var pipo = formulaires.GetEnumerator();
    pipo.MoveNext();
    IWebSitesService siteService = ServiceLocator.Instance.Retrieve<IWebSitesService>();
    Site.BeginEdit();
    Site.AuthenticationForm = pipo.Current.Value;
    Site.EndEdit();
    bool ok = siteService.MettreAJour(Site);
    siteService.AppliquerLesChangements();
 
    // On nettoie le XML.
    if (ok && (oldForm != null))
    {
        // Attention, le nettoyage des champs n'est pas fait lors de la suppression
        // du formulaire. Il faudrait modifier le code de FormsServices pour cela.
        IFormFieldsService formFieldService = ServiceLocator.Instance.Retrieve<IFormFieldsService>();
        foreach (FormFieldModele ff in oldForm.FieldList)
        {
            formFieldService.Supprimer(ff);
        }
        // Comme on passe par un singleton on pourrait optimiser en
        // ne faisant qu'un AppliquerLesChangements.
        // Cela dit, avec une autre couche service...
        formFieldService.AppliquerLesChangements();
        IFormsService formService = ServiceLocator.Instance.Retrieve<IFormsService>();
        formService.Supprimer(oldForm);
        formService.AppliquerLesChangements();
    }
}
#endregion Helper

Ce code fait appelle à une méthode de WebSiteModeleque nous n'avons pas encore définie. En voici une première version :

public Dictionary<string, FormModele> GetFormList()
{
    // On vérifie qu'une aspiration n'est pas en cours.
    if (_aspirationEnCours)
    {
        return null;
    }
 
    // On vérifie que la première page est bien une page.
    if (!DownloadHelper.IsPage(BaseURL))
    {
        return null;
    }
 
    // On est maintenant certain qu'il s'agit d'une page.
    FormProcessor formProc = new FormProcessor(new UrlFileMatcher(this, BaseURL));
    return formProc.ProcessPage(DownloadHelper.GetPage(BaseURL));
}

Pourquoi, première version ? Parce que cette version n'est pas très propre. Au minimum il faudrait bloque l'IHM. Oui mais voilà, bloquer l'IHM induit pleins de modifications que nous avons décidé de faire plus tard...
Nous devons tout de même modifier le template d'affichage d'un site pour que l'utilisateur puisse indiquer s'il veut ou non une authentification (dans DataTemplates.xaml) et la vue détaillée d'un site pour y placer le bouton lançant la recherche des formulaires (DetailSite.xaml). Cela donne (dans le même ordre) :

<DataTemplate DataType="{x:Type viewModels:WebSiteViewModel}">
    <Grid x:Name="grid1" Height="Auto" Width="Auto" Margin="5"
           bhv:EtatVisuel.EtatVisuel="{Binding EtatCourant}">
        <VisualStateManager.VisualStateGroups>
            ...
        </VisualStateManager.VisualStateGroups>

        <Grid x:Name="grid" d:LayoutOverrides="Width" VerticalAlignment="Top"
               Background="{x:Null}" Margin="0,0,5,0" DataContext="{Binding Mode=OneWay}">
            <Grid.RowDefinitions>
                <RowDefinition Height="*" />
                <RowDefinition Height="*" />
                <RowDefinition Height="*" />
                <RowDefinition Height="*" />
                <RowDefinition Height="*" />
                <RowDefinition Height="*" />
                <RowDefinition Height="*" />
            </Grid.RowDefinitions>
                ...
            <CheckBox x:Name="checkBoxBourrin" IsEnabled="False"
                       IsThreeState="False" Content="Activation du mode bourrin (peut avoir des effets secondaires indésirables) ?"
                       IsChecked="{Binding Site.BourrinMode, ValidatesOnDataErrors=true}"
                       Grid.ColumnSpan="2" Grid.Row="5" Margin="0,10,0,0" />
            <CheckBox x:Name="checkBoxAuthentication" IsEnabled="False"
                       IsThreeState="False" Content="Activation de l'authentification formulaire ?"
                       IsChecked="{Binding Site.Authenticate, ValidatesOnDataErrors=true}"
                       Grid.ColumnSpan="2" Grid.Row="6" Margin="0,10,0,0" />
        </Grid>
    </Grid>
    <DataTemplate.Triggers>
        <DataTrigger Binding="{Binding Site.EstEnEdition}" Value="True">
            ...
            <Setter TargetName="checkBoxAuthentication" Property="IsEnabled" Value="True" />
        </DataTrigger>
    </DataTemplate.Triggers>
</DataTemplate>
<Button Content="Aspire" Grid.Row="5" Grid.ColumnSpan="2" Margin="0,10,0,5" HorizontalAlignment="Center" Width="100"
        Visibility="{Binding Site.Authenticate, Converter={StaticResource ReverseBooleanToVisibilityConverter}}"
        Command="{Binding AspirerSiteCommand, Mode=OneWay}" />
<Grid Grid.Row="5" Grid.ColumnSpan="2" Visibility="{Binding Site.Authenticate, Converter={StaticResource BooleanToVisibilityConverter}}">
    <Grid.ColumnDefinitions>
        <ColumnDefinition />
        <ColumnDefinition />
    </Grid.ColumnDefinitions>
    <Button Content="Conf. authent." Grid.Column="0" Margin="0,10,0,5" HorizontalAlignment="Center" Width="100" Command="{Binding ConfigurerAuthentCommand, Mode=OneWay}" />
    <Button Content="Aspire" Grid.Column="1" Margin="0,10,0,5" HorizontalAlignment="Center" Width="100" Command="{Binding AspirerSiteCommand, Mode=OneWay}" />
</Grid>

Les puristes peuvent (doivent) agrandir la fenêtre de visualisation du template pour éviter les ascenseurs. Passez la hauteur de 250 à 280 et c'est bon.
En ce qui concerne le XAML précédent, il nous manque un convertisseur. Nous l'ajoutons donc au projet Aspirateur.Views dans le répertoire Converters. Son code est le suivant :

using System;
using System.Globalization;
using System.Windows;
using System.Windows.Data;
 
namespace Aspirateur.Views.Converters
{
    [ValueConversion(typeof(bool), typeof(Visibility))]
    public class BooleanToVisibilityConverter : IValueConverter
    {
        #region Constructeurs / destructeur
        public BooleanToVisibilityConverter()
            : this(true, false) { }
 
        public BooleanToVisibilityConverter(bool collapseWhenInvisible)
            : this(collapseWhenInvisible, false) { }
 
        public BooleanToVisibilityConverter(bool collapseWhenInvisible, bool reverseVisibility)
            : base()
        {
            CollapseWhenInvisible = collapseWhenInvisible;
            ReverseVisibility = reverseVisibility;
        }
        #endregion Constructeurs / destructeur
 
        #region Proprietes
        public bool CollapseWhenInvisible { get; set; }
 
        public bool ReverseVisibility { get; set; }
 
        public Visibility FalseVisibility
        {
            get
            {
                if (CollapseWhenInvisible)
                {
                    return Visibility.Collapsed;
                }
                else
                {
                    return Visibility.Hidden;
                }
            }
        }
        #endregion Proprietes
 
        #region Implementation de IValueConverter
        public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
        {
            if (value is bool && (bool)value)
            {
                return ReverseVisibility ? FalseVisibility : Visibility.Visible;
            }
 
            return ReverseVisibility ? Visibility.Visible : FalseVisibility;
        }
 
        public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
        {
            bool ret = (value is Visibility) && (((Visibility)value) == Visibility.Visible);
 
            return ReverseVisibility ? !ret : ret;
        }
        #endregion Implementation de IValueConverter
    }
}

Ce convertisseur est paramétrable, ce qui nous permet de l'utiliser soit pour afficher soit pour cacher, simplement en modifiant l'initialisation. Nous ajoutons donc au fichier Converters.xaml du répertoire Resources du projet Aspirateur.Viewsles deux déclarations suivantes :

<conv:BooleanToVisibilityConverter CollapseWhenInvisible="True" ReverseVisibility="False"  x:Key="BooleanToVisibilityConverter" />
<conv:BooleanToVisibilityConverter CollapseWhenInvisible="True" ReverseVisibility="True"  x:Key="ReverseBooleanToVisibilityConverter" />

Si nous lançons l'aspirateur, nous pouvons indiquer que nous souhaitons qu'une authentification soit effectuée au début de l'aspiration. Nous pouvons également rechercher les formulaires contenus dans la première page.
Malheureusement, sauf sous débogueur, il n'est pas encore possible de choisir le bon formulaire. De même, aucune information sur le formulaire n'est visible dans l'IHM.
Si vous souhaitez voir et/ou modifier la configuration du formulaire, vous devez éditer le fichier XML.
Cela dit, si vous avez écrit tout le code vous-même, cela a dû vous prendre un moment, il est temps de vous reposer.

Prenez des forces pour le prochain billet, les modifications à apporter à l'IHM ne sont pas mineures.

Si vous n'avez rien tapé et souhaitez récupérer le code à jour, c'est ici

Publicité
Publicité
Commentaires
Fanfois et ses papillons
Publicité
Archives
Publicité