asp.net-coreblazor-server-siderecaptcha-v3

Blazor recaptcha validation attribute IHttpContextAccessor is always null


I thought I would have a go at using Blazor server-side, and so far I've managed to overcome most headaches one way or another and enjoyed it, until now.

I'm trying to write a validator for Google Recaptcha v3, which requires a users IP address. Usually I would just get the IHttpContextAccessor with:

var httpContextAccessor = (IHttpContextAccessor)validationContext.GetService(typeof(IHttpContextAccessor));

But that now returns null! I also found that trying to get IConfiguration in the same way failed, but for that, I could just make a static property in Startup.cs.

This is the last hurdle in a days work, and it's got me baffled.

Any ideas on how to get that IP address into a validator?

Thanks!

Edit:

I just found the error making httpContextAccessor null!

((System.RuntimeType)validationContext.ObjectType).DeclaringMethodthrew an exception of type 'System.InvalidOperationException'

this is the validator:

public class GoogleReCaptchaValidationAttribute : ValidationAttribute
    {

    protected override ValidationResult IsValid(object value, ValidationContext validationContext)
    {
        Lazy<ValidationResult> errorResult = new Lazy<ValidationResult>(() => new ValidationResult("Google reCAPTCHA validation failed", new String[] { validationContext.MemberName }));

        if (value == null || String.IsNullOrWhiteSpace(value.ToString()))
        {
            return errorResult.Value;
        }

        var configuration = Startup.Configuration;
        string reCaptchResponse = value.ToString();
        string reCaptchaSecret = configuration["GoogleReCaptcha:SecretKey"];
        IHttpContextAccessor httpContextAccessor = validationContext.GetService(typeof(IHttpContextAccessor)) as IHttpContextAccessor;
        var content = new FormUrlEncodedContent(new[]
        {
            new KeyValuePair<string, string>("secret", reCaptchaSecret),
            new KeyValuePair<string, string>("response", reCaptchResponse),
            new KeyValuePair<string, string>("remoteip", httpContextAccessor.HttpContext.Connection.RemoteIpAddress.ToString())
        });

        HttpClient httpClient = new HttpClient();
        var httpResponse = httpClient.PostAsync("https://www.google.com/recaptcha/api/siteverify", content).Result;
        if (httpResponse.StatusCode != HttpStatusCode.OK)
        {
            return errorResult.Value;
        }

        String jsonResponse = httpResponse.Content.ReadAsStringAsync().Result;
        dynamic jsonData = JObject.Parse(jsonResponse);
        if (jsonData.success != true.ToString().ToLower())
        {
            return errorResult.Value;
        }

        return ValidationResult.Success;

    }
}

Solution

  • For this issue, it is caused by that when DataAnnotationsValidator call AddDataAnnotationsValidation, it did not pass IServiceProvider to ValidationContext.

    For this issue, you could check Make dependency resolution available for EditContext form validation so that custom validators can access services. #11397

    private static void ValidateModel(EditContext editContext, ValidationMessageStore messages)
    {
        var validationContext = new ValidationContext(editContext.Model);
        var validationResults = new List<ValidationResult>();
        Validator.TryValidateObject(editContext.Model, validationContext, validationResults, true);
    
        // Transfer results to the ValidationMessageStore
        messages.Clear();
        foreach (var validationResult in validationResults)
        {
            foreach (var memberName in validationResult.MemberNames)
            {
                messages.Add(editContext.Field(memberName), validationResult.ErrorMessage);
            }
        }
    
        editContext.NotifyValidationStateChanged();
    }
    

    For a workaround, you could implement your own DataAnnotationsValidator and AddDataAnnotationsValidation .

    Follow steps below:

    1. Custom DataAnnotationsValidator

      public class DIDataAnnotationsValidator: DataAnnotationsValidator
      {
          [CascadingParameter] EditContext DICurrentEditContext { get; set; }
      
          [Inject]
          protected IServiceProvider ServiceProvider { get; set; }
          protected override void OnInitialized()
          {
              if (DICurrentEditContext == null)
              {
                  throw new InvalidOperationException($"{nameof(DataAnnotationsValidator)} requires a cascading " +
                      $"parameter of type {nameof(EditContext)}. For example, you can use {nameof(DataAnnotationsValidator)} " +
                      $"inside an EditForm.");
              }
      
              DICurrentEditContext.AddDataAnnotationsValidationWithDI(ServiceProvider);
          }
      }
      
    2. Custom EditContextDataAnnotationsExtensions

      public static class EditContextDataAnnotationsExtensions
      {
          private static ConcurrentDictionary<(Type ModelType, string FieldName), PropertyInfo> _propertyInfoCache
          = new ConcurrentDictionary<(Type, string), PropertyInfo>();
      
          public static EditContext AddDataAnnotationsValidationWithDI(this EditContext editContext, IServiceProvider serviceProvider)
          {
              if (editContext == null)
              {
                  throw new ArgumentNullException(nameof(editContext));
              }
      
              var messages = new ValidationMessageStore(editContext);
      
              // Perform object-level validation on request
              editContext.OnValidationRequested +=
                  (sender, eventArgs) => ValidateModel((EditContext)sender, serviceProvider, messages);
      
              // Perform per-field validation on each field edit
              editContext.OnFieldChanged +=
                  (sender, eventArgs) => ValidateField(editContext, serviceProvider, messages, eventArgs.FieldIdentifier);
      
              return editContext;
          }
          private static void ValidateModel(EditContext editContext, IServiceProvider serviceProvider,ValidationMessageStore messages)
          {
              var validationContext = new ValidationContext(editContext.Model, serviceProvider, null);
              var validationResults = new List<ValidationResult>();
              Validator.TryValidateObject(editContext.Model, validationContext, validationResults, true);
      
              // Transfer results to the ValidationMessageStore
              messages.Clear();
              foreach (var validationResult in validationResults)
              {
                  foreach (var memberName in validationResult.MemberNames)
                  {
                      messages.Add(editContext.Field(memberName), validationResult.ErrorMessage);
                  }
              }
      
              editContext.NotifyValidationStateChanged();
          }
      
          private static void ValidateField(EditContext editContext, IServiceProvider serviceProvider, ValidationMessageStore messages, in FieldIdentifier fieldIdentifier)
          {
              if (TryGetValidatableProperty(fieldIdentifier, out var propertyInfo))
              {
                  var propertyValue = propertyInfo.GetValue(fieldIdentifier.Model);
                  var validationContext = new ValidationContext(fieldIdentifier.Model, serviceProvider, null)
                  {
                      MemberName = propertyInfo.Name
                  };
                  var results = new List<ValidationResult>();
      
                  Validator.TryValidateProperty(propertyValue, validationContext, results);
                  messages.Clear(fieldIdentifier);
                  messages.Add(fieldIdentifier, results.Select(result => result.ErrorMessage));
      
                  // We have to notify even if there were no messages before and are still no messages now,
                  // because the "state" that changed might be the completion of some async validation task
                  editContext.NotifyValidationStateChanged();
              }
          }
      
          private static bool TryGetValidatableProperty(in FieldIdentifier fieldIdentifier, out PropertyInfo propertyInfo)
          {
              var cacheKey = (ModelType: fieldIdentifier.Model.GetType(), fieldIdentifier.FieldName);
              if (!_propertyInfoCache.TryGetValue(cacheKey, out propertyInfo))
              {
                  // DataAnnotations only validates public properties, so that's all we'll look for
                  // If we can't find it, cache 'null' so we don't have to try again next time
                  propertyInfo = cacheKey.ModelType.GetProperty(cacheKey.FieldName);
      
                  // No need to lock, because it doesn't matter if we write the same value twice
                  _propertyInfoCache[cacheKey] = propertyInfo;
              }
      
              return propertyInfo != null;
          }
      
      }
      
    3. Replace DataAnnotationsValidator with DIDataAnnotationsValidator

      <EditForm Model="@starship" OnValidSubmit="@HandleValidSubmit">
          @*<DataAnnotationsValidator />*@
          <DIDataAnnotationsValidator />
          <ValidationSummary />    
      </EditForm>
      
    4. For IHttpContextAccessor, you need to register in Startup.cs like

      public void ConfigureServices(IServiceCollection services)
      {
          services.AddRazorPages();
          services.AddServerSideBlazor();
      
          services.AddHttpContextAccessor();
      }