J'étais il y a quelques jours en mission de conseil sur WCF, et je suis tombé sur une erreur assez typique dans des solutions basées sur une plateforme de Services exposée sur Internet. Il s'agit du degré de confiance que l'on a tendance à avoir envers le client. Voici par exemple un morceau de code assez typique :

Contrats :

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.ServiceModel;
using System.Runtime.Serialization;

namespace ServiceContracts
{
    [DataContract]
    public class Consumer
    {
        [DataMember]
        public Guid ID { get; set; }
        [DataMember]
        public string Name { get; set; }
        [DataMember]
        public string Email { get; set; }
    }


    [ServiceContract]
    public interface IMyConsumersService
    {
        [OperationContract]
        IEnumerable<Consumer> GetConsumersPage( int pageIndex, int pageSize, out int pageCount);
    }
}

Implémentation :

public class MyConsumersService : IMyConsumersService
    {
        #region IMyConsumersService Members

        public IEnumerable<Consumer> GetConsumersPage(int pageIndex, int pageSize, out int pageCount)
        {
            // Security Checks
            var userID = SecurityHelper.GetCurrentUserID();
            SecurityHelper.AssertPermission(userID, Permission.BrowseConsumers);

            IConsumerProvider dataProvider = ServiceProvider.GetService<IConsumerProvider>();
            return dataProvider.GetConsumersOfUser(userID, pageIndex, pageSize, out pageCount);
        }

        #endregion
    }

Dans cet exemple, on peut déjà voir des pratiques tout à fait bonnes : Injection de dépendance pour ne pas référencer la couche DAL directement (idéal pour assurer la testabilité du service en "Mockant" la DAL), ainsi qu'une gestion centralisée des permissions des utilisateurs, afin de ne pas polluer outre mesure le code des services, qui doit se concentrer sur le métier.

 

Il y a cependant quelque chose qui ne va pas quand on sait que ce service est sensé être accessible depuis Internet, et voici donc la question que j'ai posée à mon client : "Comment pouvez vous être sûr que le client ne va pas vous demander 1500000 lignes et ainsi consommer beaucoup plus de ressources que ce que vous ne voudriez ?".

La réponse fut : "L'application cliente ne propose que 3 options : 5, 20, et 100 lignes par page".

 

C'est exactement le genre de justification complètement invalide dans un scénario tel que celui là : De même que l'on ne peut faire confiance en l'utilisateur qui appelle le service (ainsi on effectue des assertions pour vérifier qu'il possède les droits nécessaire), nous ne pouvons pas faire confiance en l'application cliente, car rien n'empêche aux utilisateurs du service de développer leurs propres applications consommant ce service, adaptées à leurs propres besoins métiers. Ainsi, nous devons donc faire une validation des paramètres d'entrée afin d'imposer des limites.

La manière la plus simple de prime abord (et dans ce cas précis, c'est certainement la meilleure solution, vu le peu de paramètres à valider), est de faire une validation manuelle dans le code du service lui-même :

public IEnumerable<Consumer> GetConsumersPage(int pageIndex, int pageSize, out int pageCount)
        {
            // Security Checks
            var userID = SecurityHelper.GetCurrentUserID();
            SecurityHelper.AssertPermission(userID, Permission.BrowseConsumers);

            // Technical validation :
            ValidationResume resume = new ValidationResume();
            if (pageIndex < 0)
                resume.Entries.Add(new ValidationEntry { Name = "pageIndex", 
ValidationMessage = "pageIndex should be >= 0" }); if (pageSize > 200) resume.Entries.Add(new ValidationEntry { Name = "pageSize",
ValidationMessage = "pageSize cannot be > 200" }); if (resume.Entries.Count > 0) throw new FaultException<ValidationResume>(resume); IConsumerProvider dataProvider = ServiceProvider.GetService<IConsumerProvider>(); return dataProvider.GetConsumersOfUser(userID, pageIndex, pageSize, out pageCount); }

Le problème de cette technique c'est qu'elle est très intrusive vis à vis du code métier. Sur un projet d'une certaine taille, cela peut devenir compliqué à maintenir, et représenter un volume de code très conséquent qu'il peut être très tentant de factoriser.

 

WCF nous permet de faire ce genre de choses, en créant un attribut implémentant IOperationBehavior qui nous permettra d'injecter notre code dans la stack WCF côté serveur ou côté client, et nous permettant de faire notre validation avant même l'appel de la méthode. Pour ceci, nous devrons aussi implémenter l'interface IParameterInspector fournie par WCF. Notre implémentation regardera des Attributs que nous placerons sur les paramètres de nos OperationContracts afin de les valider à chaque appel.

Interface IValidator et attributs applicables sur des paramètres de méthode :

public interface IValueValidator
    {
        bool Validate(object value, out string validationMessage);
    }
[AttributeUsage(AttributeTargets.Property | AttributeTargets.Parameter)]
    public class MaxValueAttribute : Attribute, IValueValidator
    {
        private double _compareValue;

        public MaxValueAttribute(double maxValue)
        {
            _compareValue = maxValue;
        }

        #region IValueValidator Members

        public bool Validate(object value, out string validationMessage)
        {
            var toCompare = Convert.ToDouble(value);
            if (toCompare > _compareValue)
            {
                validationMessage = "Maximum value is " + _compareValue.ToString();
                return false;
            }
            validationMessage = null;
            return true;
        }

        #endregion
    }

Classe implémentant IParameterInspector et permettant l'exécution des différents validateurs :

public class OperationInputValidator : IParameterInspector
    {
        List<ValidatorEntry> _validators;
        private MethodInfo _operationContractInfo;
        public OperationInputValidator(MethodInfo operationContractInfo)
        {
            _operationContractInfo = operationContractInfo;
            _validators = ValidatorEntry.GetValidators(operationContractInfo).ToList();
        }
        private class ValidatorEntry
        {
            public string ParameterName { get; set; }
            public int ParameterIndex { get; set; }
            public IValueValidator Validator { get; set; }

            public static IEnumerable<ValidatorEntry> GetValidators(MethodInfo operationContractInfo)
            {
                foreach (var param in operationContractInfo.GetParameters())
                {
                    foreach (var validator in param.GetCustomAttributes(false).OfType<IValueValidator>())
                    {
                        yield return new ValidatorEntry
                        {
                            ParameterIndex = param.Position,
                            ParameterName = param.Name,
                            Validator = validator
                        };
                    }
                }
            }
        }


        #region IParameterInspector Members

        public void AfterCall(string operationName, object[] outputs, object returnValue, object correlationState)
        {
        }

        public object BeforeCall(string operationName, object[] inputs)
        {
            ValidationResume resume = new ValidationResume();
            foreach (var validator in _validators)
            {
                string validationMessage;
                if (!validator.Validator.Validate(inputs[validator.ParameterIndex], out validationMessage))
                {
                    resume.Entries.Add(new ValidationEntry
                    {
                        Name = validator.ParameterName,
                        ValidationMessage = validationMessage
                    });
                }
            }
            if (resume.Entries.Count > 0)
                throw new FaultException<ValidationResume>(resume,resume.ToString());
            return null;
        }

        #endregion
    }

Attribut branchant le ParameterInspector sur un OperationContract WCF :

 

[AttributeUsage(AttributeTargets.Method, Inherited = false)]
    public sealed class RequiresInputValidationAttribute : Attribute, IOperationBehavior
    {

        #region IOperationBehavior Members

        public void AddBindingParameters(OperationDescription operationDescription, 
System.ServiceModel.Channels.BindingParameterCollection bindingParameters) { } public void ApplyClientBehavior(OperationDescription operationDescription,
System.ServiceModel.Dispatcher.ClientOperation clientOperation) { } public void ApplyDispatchBehavior(OperationDescription operationDescription,
System.ServiceModel.Dispatcher.DispatchOperation dispatchOperation) { dispatchOperation.ParameterInspectors.Add(new OperationInputValidator(operationDescription.SyncMethod)); } public void Validate(OperationDescription operationDescription) { } #endregion }

Et maintenant, nous pouvons modifier notre contrat de service afin de valider automatiquement les paramètres grâce à des attributs :

[ServiceContract]
    public interface IMyConsumersService
    {
        [OperationContract]
        [RequiresInputValidation]
        [FaultContract(typeof(ValidationResume))]
        IEnumerable<Consumer> GetConsumersPage
            ([MinValue(0)] int pageIndex, [MinValue(1), MaxValue(100)] int pageSize, out int pageCount);
    }

 

La mise en place de cette technique est assez complexe, mais se fait très vite oubliée à l'utilisation. En effet, tout se fait de manière déclarative et très concise : si l'on ne considère que la modification apportée au contrat de service, cela a effectivement un aspect très simple, et à la portée de n'importe quel développeur. Sur un gros projet invoquant des développeurs d'un niveau inégal, ceci reste donc tout à fait exploitable.

Un autre aspect de ce modèle est son extensibilité. En effet, pour rajouter une règle de validation, il suffit d'écrire une classe dérivant de Attribute et implémentant IValueValidator. Voici par exemple une autre règle interdisant une valeur nulle :

[AttributeUsage(AttributeTargets.Parameter)]
    public class RequiredAttribute : Attribute, IValueValidator
    {
        #region IValueValidator Members

        public bool Validate(object value, out string validationMessage)
        {
            if (value == null)
            {
                validationMessage = "Value must not be null";
                return false;
            }
            var asString = value as string;
            if (asString != null && asString.Length == 0)
            {
                validationMessage = "Value must not be empty";
                return false;
            }

            validationMessage = null;
            return true;
        }

        #endregion
    }

Nous pouvons alors placer un attribut [Required] sur un paramètre d'opération pour forcer une valeur non nulle / non vide.

Bien sûr, rien ne nous empêche de créer des validateurs pour des types complexes, et s'en servir pour valider des entités métier...

 

Voilà donc une solution élégante pour la validation des paramètres d'entrée d'un service WCF.

Les sources de l'exemple sont disponibles ici.

Mots clés Technorati : ,,,