Skip to content

Commit

Permalink
Allow specifying expected action and verify it in v3 response
Browse files Browse the repository at this point in the history
  • Loading branch information
sleeuwen committed Sep 28, 2022
1 parent 96518fd commit 8d2c071
Show file tree
Hide file tree
Showing 9 changed files with 279 additions and 27 deletions.
139 changes: 133 additions & 6 deletions AspNetCore.ReCaptcha.Tests/ReCaptchaServiceTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -16,23 +16,23 @@ namespace AspNetCore.ReCaptcha.Tests
{
public class ReCaptchaServiceTests
{
private ReCaptchaService CreateService(HttpClient httpClient = null, Mock<IOptions<ReCaptchaSettings>> reCaptchaSettingsMock = null, ILogger<ReCaptchaService> logger = null)
private ReCaptchaService CreateService(HttpClient httpClient = null, ReCaptchaSettings reCaptchaSettings = null, ILogger<ReCaptchaService> logger = null)
{
httpClient ??= new HttpClient();

if (reCaptchaSettingsMock == null)
if (reCaptchaSettings == null)
{
var reCaptchaSettings = new ReCaptchaSettings()
reCaptchaSettings = new ReCaptchaSettings()
{
SecretKey = "123",
SiteKey = "123",
Version = ReCaptchaVersion.V2
};

reCaptchaSettingsMock = new Mock<IOptions<ReCaptchaSettings>>();
reCaptchaSettingsMock.Setup(x => x.Value).Returns(reCaptchaSettings);
}

var reCaptchaSettingsMock = new Mock<IOptions<ReCaptchaSettings>>();
reCaptchaSettingsMock.Setup(x => x.Value).Returns(reCaptchaSettings);

logger ??= new NullLogger<ReCaptchaService>();

return new ReCaptchaService(httpClient, reCaptchaSettingsMock.Object, logger);
Expand Down Expand Up @@ -123,5 +123,132 @@ public void TestVerifyWithErrorAsync(string errorCode, LogLevel expectedLogLevel
Assert.Equal(expectedLogLevel, logger.LogEntries[1].LogLevel);
Assert.Equal(expectedLogMessage, logger.LogEntries[1].Message);
}

[Fact]
public void TestVerifyWithActionReturnsFalseIfInvalidAction()
{
var reCaptchaResponse = new ReCaptchaResponse()
{
Action = "Test",
ChallengeTimestamp = new DateTime(2022, 2, 10, 15, 14, 13),
Hostname = "Test",
Success = true,
Score = 1.0,
};

var mockHttpMessageHandler = new Mock<HttpMessageHandler>();

mockHttpMessageHandler.Protected()
.Setup<Task<HttpResponseMessage>>(
"SendAsync",
ItExpr.IsAny<HttpRequestMessage>(),
ItExpr.IsAny<CancellationToken>())
.ReturnsAsync(new HttpResponseMessage { StatusCode = HttpStatusCode.OK, Content = new StringContent(JsonSerializer.Serialize(reCaptchaResponse), Encoding.UTF8,"application/json")});

var httpClient = new HttpClient(mockHttpMessageHandler.Object)
{
BaseAddress = new Uri("https://www.google.com/recaptcha/"),
};

var logger = new TestLogger<ReCaptchaService>();

var reCaptchaService = CreateService(httpClient, logger: logger, reCaptchaSettings: new ReCaptchaSettings
{
Version = ReCaptchaVersion.V3,
});

var result = reCaptchaService.VerifyAsync("123", "Test2").Result;

mockHttpMessageHandler.Protected().Verify("SendAsync", Times.Exactly(1), ItExpr.IsAny<HttpRequestMessage>(), ItExpr.IsAny<CancellationToken>());
Assert.False(result);
}

[Fact]
public void TestVerifyWithActionReturnsFalseIfScoreLessThanActionThreshold()
{
var reCaptchaResponse = new ReCaptchaResponse()
{
Action = "Test",
ChallengeTimestamp = new DateTime(2022, 2, 10, 15, 14, 13),
Hostname = "Test",
Success = true,
Score = 0.7,
};

var mockHttpMessageHandler = new Mock<HttpMessageHandler>();

mockHttpMessageHandler.Protected()
.Setup<Task<HttpResponseMessage>>(
"SendAsync",
ItExpr.IsAny<HttpRequestMessage>(),
ItExpr.IsAny<CancellationToken>())
.ReturnsAsync(new HttpResponseMessage { StatusCode = HttpStatusCode.OK, Content = new StringContent(JsonSerializer.Serialize(reCaptchaResponse), Encoding.UTF8,"application/json")});

var httpClient = new HttpClient(mockHttpMessageHandler.Object)
{
BaseAddress = new Uri("https://www.google.com/recaptcha/"),
};

var logger = new TestLogger<ReCaptchaService>();

var reCaptchaService = CreateService(httpClient, logger: logger, reCaptchaSettings: new ReCaptchaSettings
{
Version = ReCaptchaVersion.V3,
ScoreThreshold = 0.5,
ActionThresholds =
{
["Test"] = 0.8,
},
});

var result = reCaptchaService.VerifyAsync("123", "Test").Result;

mockHttpMessageHandler.Protected().Verify("SendAsync", Times.Exactly(1), ItExpr.IsAny<HttpRequestMessage>(), ItExpr.IsAny<CancellationToken>());
Assert.False(result);
}

[Fact]
public void TestVerifyWithAction()
{
var reCaptchaResponse = new ReCaptchaResponse()
{
Action = "Test",
ChallengeTimestamp = new DateTime(2022, 2, 10, 15, 14, 13),
Hostname = "Test",
Success = true,
Score = 1.0,
};

var mockHttpMessageHandler = new Mock<HttpMessageHandler>();

mockHttpMessageHandler.Protected()
.Setup<Task<HttpResponseMessage>>(
"SendAsync",
ItExpr.IsAny<HttpRequestMessage>(),
ItExpr.IsAny<CancellationToken>())
.ReturnsAsync(new HttpResponseMessage { StatusCode = HttpStatusCode.OK, Content = new StringContent(JsonSerializer.Serialize(reCaptchaResponse), Encoding.UTF8,"application/json")});

var httpClient = new HttpClient(mockHttpMessageHandler.Object)
{
BaseAddress = new Uri("https://www.google.com/recaptcha/"),
};

var logger = new TestLogger<ReCaptchaService>();

var reCaptchaService = CreateService(httpClient, logger: logger, reCaptchaSettings: new ReCaptchaSettings
{
Version = ReCaptchaVersion.V3,
ScoreThreshold = 0.5,
ActionThresholds =
{
["Test"] = 0.8,
},
});

var result = reCaptchaService.VerifyAsync("123", "Test").Result;

mockHttpMessageHandler.Protected().Verify("SendAsync", Times.Exactly(1), ItExpr.IsAny<HttpRequestMessage>(), ItExpr.IsAny<CancellationToken>());
Assert.True(result);
}
}
}
120 changes: 108 additions & 12 deletions AspNetCore.ReCaptcha.Tests/ValidateReCaptchaAttributeTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -48,9 +48,9 @@ public async Task VerifyAsyncReturnsBoolean(bool success)
{
var reCaptchaServiceMock = new Mock<IReCaptchaService>();

reCaptchaServiceMock.Setup(x => x.VerifyAsync(It.IsAny<string>())).Returns(Task.FromResult(success));
reCaptchaServiceMock.Setup(x => x.VerifyAsync(It.IsAny<string>(), null)).Returns(Task.FromResult(success));

var filter = new ValidateRecaptchaFilter(reCaptchaServiceMock.Object, "", "");
var filter = new ValidateRecaptchaFilter(reCaptchaServiceMock.Object, null, "", "");

var expected = new StringValues("123");

Expand Down Expand Up @@ -78,7 +78,7 @@ Task<ActionExecutedContext> Next()
}

await filter.OnActionExecutionAsync(actionExecutingContext, Next);
reCaptchaServiceMock.Verify(x => x.VerifyAsync(It.IsAny<string>()), Times.Once);
reCaptchaServiceMock.Verify(x => x.VerifyAsync(It.IsAny<string>(), null), Times.Once);
if(!success)
Assert.Equal(1, modelState.ErrorCount);
}
Expand All @@ -88,9 +88,9 @@ public async Task VerifyAsyncLocalizesErrorMessage()
{
var reCaptchaServiceMock = new Mock<IReCaptchaService>();

reCaptchaServiceMock.Setup(x => x.VerifyAsync(It.IsAny<string>())).Returns(Task.FromResult(false));
reCaptchaServiceMock.Setup(x => x.VerifyAsync(It.IsAny<string>(), null)).Returns(Task.FromResult(false));

var filter = new ValidateRecaptchaFilter(reCaptchaServiceMock.Object, "", null);
var filter = new ValidateRecaptchaFilter(reCaptchaServiceMock.Object, null, "", null);

var expected = new StringValues("123");

Expand Down Expand Up @@ -131,12 +131,51 @@ Task<ActionExecutedContext> Next()
}

await filter.OnActionExecutionAsync(actionExecutingContext, Next);
reCaptchaServiceMock.Verify(x => x.VerifyAsync(It.IsAny<string>()), Times.Once);
reCaptchaServiceMock.Verify(x => x.VerifyAsync(It.IsAny<string>(), null), Times.Once);

Assert.Equal(1, modelState.ErrorCount);
var errorMessage = modelState.First(x => x.Key == "Recaptcha").Value.Errors.Single().ErrorMessage;
Assert.Equal("Localized error message", errorMessage);
}

[Fact]
public async Task VerifyAsyncWithAction()
{
var reCaptchaServiceMock = new Mock<IReCaptchaService>();

reCaptchaServiceMock.Setup(x => x.VerifyAsync(It.IsAny<string>(), "action")).ReturnsAsync(true);

var filter = new ValidateRecaptchaFilter(reCaptchaServiceMock.Object, "action", "", "");

var expected = new StringValues("123");

var serviceProviderMock = new Mock<IServiceProvider>();

var httpContextMock = new Mock<HttpContext>();
httpContextMock.Setup(x => x.RequestServices)
.Returns(serviceProviderMock.Object);

var modelState = new ModelStateDictionary();

var actionDescriptor = new ControllerActionDescriptor
{
ControllerTypeInfo = typeof(ValidateReCaptchaAttributeTests).GetTypeInfo(),
};

var actionContext = CreateActionContext(httpContextMock, modelState, actionDescriptor);

var actionExecutingContext = CreateActionExecutingContext(httpContextMock, actionContext, expected);

Task<ActionExecutedContext> Next()
{
var ctx = new ActionExecutedContext(actionContext, new List<IFilterMetadata>(), Mock.Of<Controller>());
return Task.FromResult(ctx);
}

await filter.OnActionExecutionAsync(actionExecutingContext, Next);
reCaptchaServiceMock.Verify(x => x.VerifyAsync(It.IsAny<string>(), "action"), Times.Once);
Assert.Equal(0, modelState.ErrorCount);
}
}

public class OnPageHandlerExecutionAsync : ValidateReCaptchaAttributeTests
Expand All @@ -161,9 +200,9 @@ public async Task VerifyAsyncReturnsBoolean(bool success)
{
var reCaptchaServiceMock = new Mock<IReCaptchaService>();

reCaptchaServiceMock.Setup(x => x.VerifyAsync(It.IsAny<string>())).Returns(Task.FromResult(success));
reCaptchaServiceMock.Setup(x => x.VerifyAsync(It.IsAny<string>(), null)).Returns(Task.FromResult(success));

var filter = new ValidateRecaptchaFilter(reCaptchaServiceMock.Object, "", "");
var filter = new ValidateRecaptchaFilter(reCaptchaServiceMock.Object, null, "", "");

var expected = new StringValues("123");

Expand All @@ -188,17 +227,17 @@ public async Task VerifyAsyncReturnsBoolean(bool success)
PageHandlerExecutionDelegate next = () => Task.FromResult(pageHandlerExecutedContext);

await filter.OnPageHandlerExecutionAsync(actionExecutingContext, next);
reCaptchaServiceMock.Verify(x => x.VerifyAsync(It.IsAny<string>()), Times.Once);
reCaptchaServiceMock.Verify(x => x.VerifyAsync(It.IsAny<string>(), null), Times.Once);
}

[Fact]
public async Task VerifyAsyncLocalizesErrorMessage()
{
var reCaptchaServiceMock = new Mock<IReCaptchaService>();

reCaptchaServiceMock.Setup(x => x.VerifyAsync(It.IsAny<string>())).Returns(Task.FromResult(false));
reCaptchaServiceMock.Setup(x => x.VerifyAsync(It.IsAny<string>(), null)).Returns(Task.FromResult(false));

var filter = new ValidateRecaptchaFilter(reCaptchaServiceMock.Object, "", "Custom Error Message");
var filter = new ValidateRecaptchaFilter(reCaptchaServiceMock.Object, null, "", "Custom Error Message");

var expected = new StringValues("123");

Expand Down Expand Up @@ -243,12 +282,69 @@ public async Task VerifyAsyncLocalizesErrorMessage()
PageHandlerExecutionDelegate next = () => Task.FromResult(pageHandlerExecutedContext);

await filter.OnPageHandlerExecutionAsync(actionExecutingContext, next);
reCaptchaServiceMock.Verify(x => x.VerifyAsync(It.IsAny<string>()), Times.Once);
reCaptchaServiceMock.Verify(x => x.VerifyAsync(It.IsAny<string>(), null), Times.Once);

Assert.Equal(1, modelState.ErrorCount);
var errorMessage = modelState.First(x => x.Key == "Recaptcha").Value.Errors.Single().ErrorMessage;
Assert.Equal("Localized error message", errorMessage);
}

[Fact]
public async Task VerifyAsyncUsesAction()
{
var reCaptchaServiceMock = new Mock<IReCaptchaService>();

reCaptchaServiceMock.Setup(x => x.VerifyAsync(It.IsAny<string>(), "action")).Returns(Task.FromResult(true));

var filter = new ValidateRecaptchaFilter(reCaptchaServiceMock.Object, "action", "", "Custom Error Message");

var expected = new StringValues("123");

var stringLocalizerMock = new Mock<IStringLocalizer>();
stringLocalizerMock.Setup(x => x["Custom Error Message"])
.Returns(new LocalizedString("", "Localized error message"));

var stringLocalizerFactory = new Mock<IStringLocalizerFactory>();
stringLocalizerFactory.Setup(x => x.Create(It.IsAny<Type>()))
.Returns(stringLocalizerMock.Object);

var serviceProviderMock = new Mock<IServiceProvider>();
serviceProviderMock.Setup(x => x.GetService(typeof(IStringLocalizerFactory)))
.Returns(stringLocalizerFactory.Object);

serviceProviderMock.Setup(x => x.GetService(typeof(IOptions<ReCaptchaSettings>)))
.Returns(new OptionsWrapper<ReCaptchaSettings>(new ReCaptchaSettings { LocalizerProvider = (type, factory) => factory.Create(type) }));

var httpContextMock = new Mock<HttpContext>();
httpContextMock.Setup(x => x.RequestServices)
.Returns(serviceProviderMock.Object);

var modelState = new ModelStateDictionary();

var actionDescriptor = new CompiledPageActionDescriptor
{
HandlerTypeInfo = typeof(ValidateReCaptchaAttributeTests).GetTypeInfo(),
};

var pageContext = CreatePageContext(new ActionContext(httpContextMock.Object, new RouteData(), actionDescriptor, modelState));

var model = new Mock<PageModel>();

var pageHandlerExecutedContext = new PageHandlerExecutedContext(
pageContext,
Array.Empty<IFilterMetadata>(),
new HandlerMethodDescriptor(),
model.Object);

var actionExecutingContext = CreatePageHandlerExecutingContext(httpContextMock, pageContext, expected, model);

PageHandlerExecutionDelegate next = () => Task.FromResult(pageHandlerExecutedContext);

await filter.OnPageHandlerExecutionAsync(actionExecutingContext, next);
reCaptchaServiceMock.Verify(x => x.VerifyAsync(It.IsAny<string>(), "action"), Times.Once);

Assert.Equal(0, modelState.ErrorCount);
}
}
}
}
2 changes: 1 addition & 1 deletion AspNetCore.ReCaptcha/IReCaptchaService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ public interface IReCaptchaService
/// </summary>
/// <param name="reCaptchaResponse">ReCaptcha Response as given by the widget.</param>
/// <returns>Returns whether the recaptcha validation was successful or not.</returns>
Task<bool> VerifyAsync(string reCaptchaResponse);
Task<bool> VerifyAsync(string reCaptchaResponse, string action = null);

/// <summary>
/// Verifies provided ReCaptcha Response.
Expand Down
16 changes: 14 additions & 2 deletions AspNetCore.ReCaptcha/ReCaptchaService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -21,13 +21,25 @@ public ReCaptchaService(HttpClient client, IOptions<ReCaptchaSettings> reCaptcha
}

/// <inheritdoc />
public async Task<bool> VerifyAsync(string reCaptchaResponse)
public async Task<bool> VerifyAsync(string reCaptchaResponse, string action = null)
{
var obj = await GetVerifyResponseAsync(reCaptchaResponse);

if (_reCaptchaSettings.Version == ReCaptchaVersion.V3)
{
return obj.Success && obj.Score >= _reCaptchaSettings.ScoreThreshold;
if (!obj.Success)
return false;

if (!string.IsNullOrEmpty(action))
{
if (action != obj.Action)
return false;

if (_reCaptchaSettings.ActionThresholds.TryGetValue(action, out var threshold))
return obj.Score >= threshold;
}

return obj.Score >= _reCaptchaSettings.ScoreThreshold;
}

return obj.Success;
Expand Down
Loading

0 comments on commit 8d2c071

Please sign in to comment.