Skip to content

Commit

Permalink
Devs who make use of reCAPTCHA V3 can / should now specify an action …
Browse files Browse the repository at this point in the history
…in the ValidateRecaptcha attribute. This will then also validate that the action does match the expected result.
  • Loading branch information
jooni91 committed Apr 20, 2022
1 parent 67c3a78 commit eebb309
Show file tree
Hide file tree
Showing 5 changed files with 140 additions and 21 deletions.
18 changes: 13 additions & 5 deletions docs/Griesoft.AspNetCore.ReCaptcha.xml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 3 additions & 1 deletion src/ReCaptcha/Filters/ValidateRecaptchaFilter.cs
Expand Up @@ -30,6 +30,8 @@ internal class ValidateRecaptchaFilter : IAsyncActionFilter

public ValidationFailedAction OnValidationFailedAction { get; set; } = ValidationFailedAction.Unspecified;

public string? Action { get; set; }

public async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next)
{
if (OnValidationFailedAction == ValidationFailedAction.Unspecified)
Expand Down Expand Up @@ -73,7 +75,7 @@ public async Task OnActionExecutionAsync(ActionExecutingContext context, ActionE
}
private bool ShouldShortCircuit(ActionExecutingContext context, ValidationResponse response)
{
if (!response.Success)
if (!response.Success || Action != response.Action)
{
_logger.LogInformation(Resources.InvalidResponseTokenMessage);

Expand Down
19 changes: 14 additions & 5 deletions src/ReCaptcha/ValidateRecaptchaAttribute.cs
Expand Up @@ -6,10 +6,11 @@
namespace Griesoft.AspNetCore.ReCaptcha
{
/// <summary>
/// Validates an incoming POST request to a controller or action, which is decorated with this attribute
/// that the header contains a valid ReCaptcha token. If the token is missing or is not valid, the action
/// will not be executed.
/// Validates an incoming request that it contains a valid ReCaptcha token.
/// </summary>
/// <remarks>
/// Can be applied to a specific action or to a controller which would validate all incoming requests to it.
/// </remarks>
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method)]
public sealed class ValidateRecaptchaAttribute : Attribute, IFilterFactory, IOrderedFilter
{
Expand All @@ -21,11 +22,18 @@ public sealed class ValidateRecaptchaAttribute : Attribute, IFilterFactory, IOrd

/// <summary>
/// If set to <see cref="ValidationFailedAction.BlockRequest"/>, the requests that do not contain a valid reCAPTCHA response token will be canceled.
/// If this is set to anything else than <see cref="ValidationFailedAction.Unspecified"/>, this will override the global behaviour,
/// which you might have set at app startup.
/// If this is set to anything else than <see cref="ValidationFailedAction.Unspecified"/>, this will override the global behavior.
/// </summary>
public ValidationFailedAction ValidationFailedAction { get; set; } = ValidationFailedAction.Unspecified;

/// <summary>
/// The name of the action that is verified.
/// </summary>
/// <remarks>
/// This is a reCAPTCHA V3 feature and should be used only when validating V3 challenges.
/// </remarks>
public string? Action { get; set; }


/// <summary>
/// Creates an instance of the executable filter.
Expand All @@ -42,6 +50,7 @@ public IFilterMetadata CreateInstance(IServiceProvider serviceProvider)
_ = filter ?? throw new InvalidOperationException(Resources.RequiredServiceNotRegisteredErrorMessage);

filter.OnValidationFailedAction = ValidationFailedAction;
filter.Action = Action;

return filter;
}
Expand Down
77 changes: 75 additions & 2 deletions tests/ReCaptcha.Tests/Filters/ValidateRecaptchaFilterTests.cs
Expand Up @@ -253,9 +253,10 @@ public async Task OnActionExecutionAsync_WhenValidationFailed_ContinuesAndAddsRe
}

[Test]
public async Task OnActionExecutionAsync_WhenValidationSuccess_ContinuesAndAddsResponseToArguments()
public async Task OnActionExecutionAsync_WhenActionDoesNotMatch_BlocksAndReturns_RecaptchaValidationFailedResult()
{
// Arrange
var action = "submit";
var httpContext = new DefaultHttpContext();
httpContext.Request.Headers.Add(RecaptchaServiceConstants.TokenKeyName, TokenValue);

Expand All @@ -270,8 +271,79 @@ public async Task OnActionExecutionAsync_WhenValidationSuccess_ContinuesAndAddsR
.Verifiable();

_filter = new ValidateRecaptchaFilter(_recaptchaServiceMock.Object, _optionsMock.Object, _logger);
_filter.Action = action;

// Act
await _filter.OnActionExecutionAsync(_actionExecutingContext, _actionExecutionDelegate);

// Assert
_recaptchaServiceMock.Verify();
Assert.IsInstanceOf<IRecaptchaValidationFailedResult>(_actionExecutingContext.Result);
}

[Test]
public async Task OnActionExecutionAsync_WhenActionDoesNotMatch_ContinuesAndAddsResponseToArguments()
{
// Arrange
var action = "submit";
var httpContext = new DefaultHttpContext();
httpContext.Request.Headers.Add(RecaptchaServiceConstants.TokenKeyName, TokenValue);

_actionExecutingContext.HttpContext = httpContext;

_recaptchaServiceMock = new Mock<IRecaptchaService>();
_recaptchaServiceMock.Setup(service => service.ValidateRecaptchaResponse(It.Is<string>(s => s == TokenValue), null))
.ReturnsAsync(new ValidationResponse
{
Success = true,
ErrorMessages = new List<string> { "invalid-input-response" }
})
.Verifiable();

_filter = new ValidateRecaptchaFilter(_recaptchaServiceMock.Object, _optionsMock.Object, _logger)
{
OnValidationFailedAction = ValidationFailedAction.ContinueRequest,
Action = action
};

_actionExecutingContext.ActionArguments.Add("argumentName", new ValidationResponse { Success = true });

// Act
await _filter.OnActionExecutionAsync(_actionExecutingContext, _actionExecutionDelegate);

// Assert
_recaptchaServiceMock.Verify();
Assert.IsInstanceOf<OkResult>(_actionExecutingContext.Result);
Assert.IsTrue((_actionExecutingContext.ActionArguments["argumentName"] as ValidationResponse).Success);
Assert.GreaterOrEqual((_actionExecutingContext.ActionArguments["argumentName"] as ValidationResponse).Errors.Count(), 1);
Assert.AreEqual(ValidationError.InvalidInputResponse, (_actionExecutingContext.ActionArguments["argumentName"] as ValidationResponse).Errors.First());
}

[Test]
public async Task OnActionExecutionAsync_WhenValidationSuccess_ContinuesAndAddsResponseToArguments()
{
// Arrange
var action = "submit";
var httpContext = new DefaultHttpContext();
httpContext.Request.Headers.Add(RecaptchaServiceConstants.TokenKeyName, TokenValue);

_actionExecutingContext.HttpContext = httpContext;

_recaptchaServiceMock = new Mock<IRecaptchaService>();
_recaptchaServiceMock.Setup(service => service.ValidateRecaptchaResponse(It.Is<string>(s => s == TokenValue), null))
.ReturnsAsync(new ValidationResponse
{
Success = true,
Action = action
})
.Verifiable();

_filter = new ValidateRecaptchaFilter(_recaptchaServiceMock.Object, _optionsMock.Object, _logger)
{
Action = action
};

_actionExecutingContext.ActionArguments.Add("argumentName", new ValidationResponse { Success = false });
_actionExecutingContext.ActionArguments.Add("argumentName", new ValidationResponse { Success = false, Action = string.Empty });

// Act
await _filter.OnActionExecutionAsync(_actionExecutingContext, _actionExecutionDelegate);
Expand All @@ -280,6 +352,7 @@ public async Task OnActionExecutionAsync_WhenValidationSuccess_ContinuesAndAddsR
_recaptchaServiceMock.Verify();
Assert.IsInstanceOf<OkResult>(_actionExecutingContext.Result);
Assert.IsTrue((_actionExecutingContext.ActionArguments["argumentName"] as ValidationResponse).Success);
Assert.AreEqual(action, (_actionExecutingContext.ActionArguments["argumentName"] as ValidationResponse).Action);
Assert.AreEqual((_actionExecutingContext.ActionArguments["argumentName"] as ValidationResponse).Errors.Count(), 0);
}

Expand Down
43 changes: 35 additions & 8 deletions tests/ReCaptcha.Tests/ValidateRecaptchaAttributeTests.cs
Expand Up @@ -11,9 +11,25 @@ namespace ReCaptcha.Tests
[TestFixture]
public class ValidateRecaptchaAttributeTests
{
[Test(Description = "CreateInstance(...) should throw InvalidOperationException if the library services are not registered.")]
public void CreateInstance_ShouldThrowWhen_ServicesNotRegistered()
{
// Arrange
var servicesMock = new Mock<IServiceProvider>();
servicesMock.Setup(provider => provider.GetService(typeof(ValidateRecaptchaFilter)))
.Returns(null);
var attribute = new ValidateRecaptchaAttribute();

// Act


// Assert
Assert.Throws<InvalidOperationException>(() => attribute.CreateInstance(servicesMock.Object));
}

[Test(Description = "CreateInstance(...) should return a new instance of " +
"ValidateRecaptchaFilter with the default value for the OnValidationFailedAction property.")]
public void CreateInstance_ShouldReturn_ValidateRecaptchaFilter_WithDefaultAction()
public void CreateInstance_ShouldReturn_ValidateRecaptchaFilter_WithDefaultOnValidationFailedAction()
{
// Arrange
var optionsMock = new Mock<IOptionsMonitor<RecaptchaOptions>>();
Expand All @@ -37,7 +53,7 @@ public void CreateInstance_ShouldReturn_ValidateRecaptchaFilter_WithDefaultActio

[Test(Description = "CreateInstance(...) should return a new instance of " +
"ValidateRecaptchaFilter with the user set value for the OnValidationFailedAction property.")]
public void CreateInstance_ShouldReturn_ValidateRecaptchaFilter_WithUserSetAction()
public void CreateInstance_ShouldReturn_ValidateRecaptchaFilter_WithUserSetOnValidationFailedAction()
{
// Arrange
var optionsMock = new Mock<IOptionsMonitor<RecaptchaOptions>>();
Expand All @@ -62,20 +78,31 @@ public void CreateInstance_ShouldReturn_ValidateRecaptchaFilter_WithUserSetActio
Assert.AreEqual(ValidationFailedAction.ContinueRequest, (filterInstance as ValidateRecaptchaFilter).OnValidationFailedAction);
}

[Test(Description = "CreateInstance(...) should throw InvalidOperationException if the library services are not registered.")]
public void CreateInstance_ShouldThrowWhen_ServicesNotRegistered()
[Test]
public void CreateInstance_ShouldReturn_ValidateRecaptchaFilter_WithUserSetAction()
{
// Arrange
var action = "submit";
var optionsMock = new Mock<IOptionsMonitor<RecaptchaOptions>>();
optionsMock.SetupGet(options => options.CurrentValue)
.Returns(new RecaptchaOptions());
var servicesMock = new Mock<IServiceProvider>();
servicesMock.Setup(provider => provider.GetService(typeof(ValidateRecaptchaFilter)))
.Returns(null);
var attribute = new ValidateRecaptchaAttribute();
.Returns(new ValidateRecaptchaFilter(null, optionsMock.Object, null))
.Verifiable();
var attribute = new ValidateRecaptchaAttribute
{
Action = action
};

// Act

var filterInstance = attribute.CreateInstance(servicesMock.Object);

// Assert
Assert.Throws<InvalidOperationException>(() => attribute.CreateInstance(servicesMock.Object));
servicesMock.Verify();
Assert.IsNotNull(filterInstance);
Assert.IsInstanceOf<ValidateRecaptchaFilter>(filterInstance);
Assert.AreEqual(action, (filterInstance as ValidateRecaptchaFilter).Action);
}
}
}

0 comments on commit eebb309

Please sign in to comment.