Skip to content

Commit

Permalink
Merge pull request #9 from jgdevlabs/dev
Browse files Browse the repository at this point in the history
Performance improvements and meta data changes
  • Loading branch information
jooni91 authored Apr 23, 2022
2 parents a9a2294 + d98399c commit 7aa8212
Show file tree
Hide file tree
Showing 15 changed files with 177 additions and 59 deletions.
106 changes: 77 additions & 29 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# ASP.NET Core reCAPTCHA
A Google reCPATCHA validation wrapper service for ASP.NET Core. With only a few simple setup steps you are ready to block bots from filling in and submitting forms on your website.
# ASP.NET Core reCAPTCHA
A Google reCAPTCHA validation wrapper service for ASP.NET Core. In only a few simple steps, you are ready to block bots from filling in and submitting forms on your website with reCAPTCHA.

This package also supports reCAPTCHA V3, but at the moment does not provide any frontend tag helpers for that. So only backend validation is supported at the moment.
The package supports V2 and V3 and comes with tag helpers that make it easy to add challenges to your forms. Also, backend validation is made easy and requires only the use of an attribute in your controllers or actions that should get validated.

[![Build Status](https://dev.azure.com/griesingersoftware/ASP.NET%20Core%20Recaptcha/_apis/build/status/jgdevlabs.aspnetcore-recaptcha?branchName=master)](https://dev.azure.com/griesingersoftware/ASP.NET%20Core%20Recaptcha/_build/latest?definitionId=17&branchName=master)
[![Build Status](https://vsrm.dev.azure.com/griesingersoftware/_apis/public/Release/badge/f9036ec9-eb1c-4aff-a2b8-27fdaa573d0f/1/2)](https://vsrm.dev.azure.com/griesingersoftware/_apis/public/Release/badge/f9036ec9-eb1c-4aff-a2b8-27fdaa573d0f/1/2)
Expand All @@ -13,16 +13,19 @@ This package also supports reCAPTCHA V3, but at the moment does not provide any

Install via [NuGet](https://www.nuget.org/packages/Griesoft.AspNetCore.ReCaptcha/) using:

``PM> Install-Package Griesoft.AspNetCore.ReCaptcha``
`PM> Install-Package Griesoft.AspNetCore.ReCaptcha`

## Quickstart

The first thing you need to do is to sign up for a new API key-pair for your project. You can follow [Google's guide](https://developers.google.com/recaptcha/intro#overview), if you haven't done that yet.
### Prequisites
You will need an API key pair which can be acquired by [signing up here](http://www.google.com/recaptcha/admin). For assistance or other questions regarding that topic, refer to [Google's guide](https://developers.google.com/recaptcha/intro#overview).

After sign-up you should now have a **Site key** and a **Secret key**. Make note of those, you will need them for the next step.
After sign-up, you should have a **Site key** and a **Secret key**. You will need those to configure the service in your app.

### Configuration

#### Settings

Open your `appsettings.json` and add the following lines:

```json
Expand All @@ -31,41 +34,57 @@ Open your `appsettings.json` and add the following lines:
"SecretKey": "<Your secret key goes here>"
}
```
**Important:** The `SiteKey` will be exposed to the public, so make sure you don't accidentally swap it with the `SecretKey`.

Make sure to place your site & secret key in the right spot. You risk to expose your secret key to the public if you switch it with the site key.
#### Service Registration

For more inforamtion about ASP.NET Core configuration check out the [Microsoft docs](https://docs.microsoft.com/en-us/aspnet/core/fundamentals/configuration/?view=aspnetcore-3.1).
Register this service by calling the `AddRecaptchaService()` method which is an extension method of `IServiceCollection`. For example:

In your `Startup.cs` you now need to add the service. Add the following line `services.AddRecaptchaService();` into the `ConfigureServices(IServiceCollection services)` method, like this for excample.
##### .NET 6

```csharp
var builder = WebApplication.CreateBuilder(args);

builder.Services.AddRecaptchaService();
```

##### Prior to .NET 6

```csharp
public void ConfigureServices(IServiceCollection services)
{
// Add other services here
services.AddRecaptchaService();

// Add other services here
}
```

### Adding a reCAPTCHA element on your view
### Adding a reCAPTCHA element to your view

First you will need to import the tag helpers. Open your `_ViewImports.cshtml` file and add the following lines:
First, import the tag helpers. Open your `_ViewImports.cshtml` file and add the following lines:

```razor
@using Griesoft.AspNetCore.ReCaptcha
@addTagHelper *, Griesoft.AspNetCore.ReCaptcha
```

Now you are ready to use the tag helpers in your views. Always add the `<recaptcha-script>` tag on the bottom of your view. This will render the script tag which will load the reCAPTCHA.js API.
Next, you need to add the `<recaptcha-script>` to every view you intend to use the reCAPTCHA. That will render the API script. Preferably you would add this somewhere close to the bottom of your body element.

Next you only need to add a `<recaptcha>` tag in your form and you are all set. This is the most simplest way of adding reCAPTCHA to your views. Now you only need to add backend validation to the controller of your view.
Now you may add a reCAPTCHA challenge to your view where ever you need it. Using the `<recaptcha />` tag in your form will render a reCAPTCHA V2 checkbox inside it.

For invisible reCAPTCHA use:
```html
<button re-invisible form-id="yourFormId">Submit</button>
```

For reCAPTCHA V3 use:
```html
<recaptcha-v3 form-id="yourFormId" action="submit">Submit</recaptcha-v3>
```

### Adding backend validation to an action

Add a using statement to `Griesoft.AspNetCore.ReCaptcha` in your controller. Next you just need to the `[ValidateRecaptcha]` attribute to the action which is triggered by your form.
Validation is done by decorating your controller or action with `[ValidateRecaptcha]`.

For example:

```csharp
using Griesoft.AspNetCore.ReCaptcha;
Expand All @@ -75,25 +94,54 @@ namespace ReCaptcha.Sample.Controllers
{
public class ExampleController : Controller
{
public IActionResult Index()
{
return View();
}

[ValidateRecaptcha]
public IActionResult FormSubmit(SomeModel model)
{
// Will hit the next line only if validation was successfull
// Will hit the next line only if validation was successful
return View("FormSubmitSuccessView");
}
}
}
```
Now each incoming request to that action will be validated for a valid reCAPTCHA token.

The default behavior for invalid tokens is a 404 (BadRequest) response. But this behavior is configurable, and you may also instead request the validation result as an argument to your action.

This can be achieved like this:

```csharp
[ValidateRecaptcha(ValidationFailedAction = ValidationFailedAction.ContinueRequest)]
public IActionResult FormSubmit(SomeModel model, ValidationResponse recaptchaResponse)
{
if (!recaptchaResponse.Success)
{
return BadRequest();
}

return View("FormSubmitSuccessView");
}
```

In case you are validating a reCAPTCHA V3 token, make sure you also add an action name to your validator.

For example:

```csharp
[ValidateRecaptcha(Action = "submit")]
public IActionResult FormSubmit(SomeModel model)
{
return View("FormSubmitSuccessView");
}
```

## Options & Customization

Now if validation would fail, the action method would never get called.
There are global defaults that you may modify on your application startup. Also, the appearance and position of V2 tags may be modified. Either globally or each tag individually.

You can configure that behaviour and a lot of other stuff globally at startup or even just seperatly for each controller or action.
All options from the [official reCAPTCHA docs](https://developers.google.com/recaptcha/intro) are available to you in this package.

### Addition information
## Detailed Documentation
Is on it's way...

For more detailed usage guides check out the wiki. You can find guides about additional configuration options, response validation behaviour, explicit rendering of tags, invisible reCAPTCHA elements and the usage of reCAPTCHA V3.
## Contributing
Contributing is heavily encouraged. :muscle: The best way of doing so is by first starting a discussion about new features or improvements you would like to make. Or, in case of a bug, report it first by creating a new issue. From there, you may volunteer to fix it if you like. 😄
1 change: 1 addition & 0 deletions ReCaptcha.sln
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution
.editorconfig = .editorconfig
azure-pipelines.yml = azure-pipelines.yml
CHANGELOG.md = CHANGELOG.md
.github\FUNDING.yml = .github\FUNDING.yml
LICENSE.md = LICENSE.md
pr-pipelines.yml = pr-pipelines.yml
README.md = README.md
Expand Down
4 changes: 2 additions & 2 deletions docs/Griesoft.AspNetCore.ReCaptcha.xml

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

2 changes: 1 addition & 1 deletion src/ReCaptcha/Configuration/RecaptchaOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ public class RecaptchaOptions
/// <summary>
/// If set to true the remote IP will be send to Google when verifying the response token. The default is false.
/// </summary>
public bool UseRemoteIp { get; set; } = false;
public bool UseRemoteIp { get; set; }

/// <summary>
/// Configure the service on a global level whether it should block / short circuit the request pipeline
Expand Down
49 changes: 49 additions & 0 deletions src/ReCaptcha/Extensions/LoggerExtensions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
using System;
using Griesoft.AspNetCore.ReCaptcha.Localization;
using Microsoft.Extensions.Logging;

namespace Griesoft.AspNetCore.ReCaptcha.Extensions
{
internal static class LoggerExtensions
{
private static readonly Action<ILogger, Exception?> _validationRequestFailed = LoggerMessage.Define(
LogLevel.Warning,
new EventId(1, nameof(ValidationRequestFailed)),
Resources.RequestFailedErrorMessage);

private static readonly Action<ILogger, Exception?> _validationRequestUnexpectedException = LoggerMessage.Define(
LogLevel.Critical,
new EventId(2, nameof(ValidationRequestUnexpectedException)),
Resources.ValidationUnexpectedErrorMessage);

private static readonly Action<ILogger, Exception?> _recaptchaResponseTokenMissing = LoggerMessage.Define(
LogLevel.Warning,
new EventId(3, nameof(RecaptchaResponseTokenMissing)),
Resources.RecaptchaResponseTokenMissing);

private static readonly Action<ILogger, Exception?> _invalidResponseToken = LoggerMessage.Define(
LogLevel.Information,
new EventId(4, nameof(InvalidResponseToken)),
Resources.InvalidResponseTokenMessage);

public static void ValidationRequestFailed(this ILogger logger)
{
_validationRequestFailed(logger, null);
}

public static void ValidationRequestUnexpectedException(this ILogger logger, Exception exception)
{
_validationRequestUnexpectedException(logger, exception);
}

public static void RecaptchaResponseTokenMissing(this ILogger logger)
{
_recaptchaResponseTokenMissing(logger, null);
}

public static void InvalidResponseToken(this ILogger logger)
{
_invalidResponseToken(logger, null);
}
}
}
2 changes: 1 addition & 1 deletion src/ReCaptcha/Extensions/TagHelperOutputExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ internal static void AddClass(this TagHelperOutput tagHelperOutput, string class

var encodedSpaceChars = SpaceChars.Where(x => !x.Equals('\u0020')).Select(x => htmlEncoder.Encode(x.ToString(CultureInfo.InvariantCulture))).ToArray();

if (SpaceChars.Any(classValue.Contains) || encodedSpaceChars.Any(value => classValue.IndexOf(value, StringComparison.Ordinal) >= 0))
if (SpaceChars.Any(classValue.Contains) || encodedSpaceChars.Any(value => classValue.Contains(value)))
{
throw new ArgumentException(null, nameof(classValue));
}
Expand Down
5 changes: 3 additions & 2 deletions src/ReCaptcha/Filters/ValidateRecaptchaFilter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
using System.Runtime.CompilerServices;
using System.Threading.Tasks;
using Griesoft.AspNetCore.ReCaptcha.Configuration;
using Griesoft.AspNetCore.ReCaptcha.Extensions;
using Griesoft.AspNetCore.ReCaptcha.Localization;
using Griesoft.AspNetCore.ReCaptcha.Services;
using Microsoft.AspNetCore.Http;
Expand Down Expand Up @@ -43,7 +44,7 @@ public async Task OnActionExecutionAsync(ActionExecutingContext context, ActionE

if (!TryGetRecaptchaToken(context.HttpContext.Request, out string? token))
{
_logger.LogWarning(Resources.RecaptchaResponseTokenMissing);
_logger.RecaptchaResponseTokenMissing();

validationResponse = new ValidationResponse()
{
Expand Down Expand Up @@ -77,7 +78,7 @@ private bool ShouldShortCircuit(ActionExecutingContext context, ValidationRespon
{
if (!response.Success || Action != response.Action)
{
_logger.LogInformation(Resources.InvalidResponseTokenMessage);
_logger.InvalidResponseToken();

if (OnValidationFailedAction == ValidationFailedAction.BlockRequest)
{
Expand Down
10 changes: 10 additions & 0 deletions src/ReCaptcha/ReCaptcha.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,9 @@
<SymbolPackageFormat>snupkg</SymbolPackageFormat>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<DocumentationFile>..\..\docs\Griesoft.AspNetCore.ReCaptcha.xml</DocumentationFile>
<PackageReadmeFile>README.md</PackageReadmeFile>
<EnableNETAnalyzers>True</EnableNETAnalyzers>
<AnalysisLevel>latest-recommended</AnalysisLevel>
</PropertyGroup>

<ItemGroup>
Expand Down Expand Up @@ -48,6 +51,13 @@
<Folder Include="Properties\" />
</ItemGroup>

<ItemGroup>
<None Include="..\..\README.md">
<Pack>True</Pack>
<PackagePath>\</PackagePath>
</None>
</ItemGroup>

<ItemGroup>
<Compile Update="Localization\Resources.Designer.cs">
<DesignTime>True</DesignTime>
Expand Down
17 changes: 13 additions & 4 deletions src/ReCaptcha/Services/RecaptchaService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
using System.Runtime.CompilerServices;
using System.Threading.Tasks;
using Griesoft.AspNetCore.ReCaptcha.Configuration;
using Griesoft.AspNetCore.ReCaptcha.Extensions;
using Griesoft.AspNetCore.ReCaptcha.Localization;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
Expand Down Expand Up @@ -34,18 +35,26 @@ public async Task<ValidationResponse> ValidateRecaptchaResponse(string token, st

try
{
var response = await _httpClient.PostAsync($"?secret={_settings.SecretKey}&response={token}{(remoteIp != null ? $"&remoteip={remoteIp}" : "")}", null)
var response = await _httpClient.PostAsync($"?secret={_settings.SecretKey}&response={token}{(remoteIp != null ? $"&remoteip={remoteIp}" : "")}", null!)
.ConfigureAwait(true);

response.EnsureSuccessStatusCode();

return JsonConvert.DeserializeObject<ValidationResponse>(
await response.Content.ReadAsStringAsync()
.ConfigureAwait(true));
.ConfigureAwait(true))
?? new ValidationResponse()
{
Success = false,
ErrorMessages = new List<string>()
{
"response-deserialization-failed"
}
};
}
catch (HttpRequestException)
{
_logger.LogWarning(Resources.RequestFailedErrorMessage);
_logger.ValidationRequestFailed();
return new ValidationResponse()
{
Success = false,
Expand All @@ -57,7 +66,7 @@ await response.Content.ReadAsStringAsync()
}
catch (Exception ex)
{
_logger.LogCritical(ex, Resources.ValidationUnexpectedErrorMessage);
_logger.ValidationRequestUnexpectedException(ex);
throw;
}
}
Expand Down
8 changes: 4 additions & 4 deletions src/ReCaptcha/TagHelpers/RecaptchaInvisibleTagHelper.cs
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ public RecaptchaInvisibleTagHelper(IOptionsMonitor<RecaptchaSettings> settings,
/// <summary>
/// Set the tabindex of the reCAPTCHA element. If other elements in your page use tabindex, it should be set to make user navigation easier.
/// </summary>
public int? TabIndex { get; set; } = null;
public int? TabIndex { get; set; }

/// <summary>
/// The id of the form that will be submitted after a successful reCAPTCHA challenge.
Expand All @@ -86,7 +86,7 @@ public RecaptchaInvisibleTagHelper(IOptionsMonitor<RecaptchaSettings> settings,
/// Set the name of your callback function, which is called when the reCAPTCHA challenge was successful.
/// A "g-recaptcha-response" token is added to your callback function parameters for server-side verification.
/// </summary>
public string Callback { get; set; } = string.Empty;
public string? Callback { get; set; }

/// <summary>
/// Set the name of your callback function, executed when the reCAPTCHA response expires and the user needs to re-verify.
Expand All @@ -101,14 +101,14 @@ public RecaptchaInvisibleTagHelper(IOptionsMonitor<RecaptchaSettings> settings,

/// <inheritdoc />
/// <exception cref="ArgumentNullException"></exception>
/// <exception cref="NullReferenceException">Thrown when both <see cref="Callback"/> and <see cref="FormId"/> are null or empty.</exception>
/// <exception cref="InvalidOperationException">Thrown when both <see cref="Callback"/> and <see cref="FormId"/> are null or empty.</exception>
public override void Process(TagHelperContext context, TagHelperOutput output)
{
_ = output ?? throw new ArgumentNullException(nameof(output));

if (string.IsNullOrEmpty(Callback) && string.IsNullOrEmpty(FormId))
{
throw new NullReferenceException(Resources.CallbackPropertyNullErrorMessage);
throw new InvalidOperationException(Resources.CallbackPropertyNullErrorMessage);
}

if (output.TagName == "button")
Expand Down
Loading

0 comments on commit 7aa8212

Please sign in to comment.