Skip to content

Commit ca3d8a3

Browse files
committed
Adding a default callback script in the form of a TagHelperComponent which will be added if no callback is set and a form id is provided instead.
This makes usage of the invisible recaptcha tag even simpler.
1 parent ae84c7b commit ca3d8a3

File tree

6 files changed

+169
-21
lines changed

6 files changed

+169
-21
lines changed

docs/Griesoft.AspNetCore.ReCaptcha.xml

Lines changed: 30 additions & 4 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/ReCaptcha/Filters/ValidateRecaptchaFilter.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ public async Task OnActionExecutionAsync(ActionExecutingContext context, ActionE
3939

4040
ValidationResponse validationResponse;
4141

42-
if (!ValidateRecaptchaFilter.TryGetRecaptchaToken(context.HttpContext.Request, out string? token))
42+
if (!TryGetRecaptchaToken(context.HttpContext.Request, out string? token))
4343
{
4444
_logger.LogWarning(Resources.RecaptchaResponseTokenMissing);
4545

@@ -57,7 +57,7 @@ public async Task OnActionExecutionAsync(ActionExecutingContext context, ActionE
5757
validationResponse = await _recaptchaService.ValidateRecaptchaResponse(token, GetRemoteIp(context)).ConfigureAwait(true);
5858
}
5959

60-
ValidateRecaptchaFilter.TryAddResponseToActionAguments(context, validationResponse);
60+
TryAddResponseToActionAguments(context, validationResponse);
6161

6262
if (!ShouldShortCircuit(context, validationResponse))
6363
{

src/ReCaptcha/ReCaptcha.csproj

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
</PropertyGroup>
2525

2626
<ItemGroup>
27+
<PackageReference Include="Microsoft.AspNetCore.Mvc.Razor" Version="2.2.0" />
2728
<PackageReference Include="Microsoft.CodeAnalysis.NetAnalyzers" Version="6.0.0">
2829
<PrivateAssets>all</PrivateAssets>
2930
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
using System;
2+
using Microsoft.AspNetCore.Razor.TagHelpers;
3+
4+
namespace Griesoft.AspNetCore.ReCaptcha.TagHelpers
5+
{
6+
/// <summary>
7+
/// This tag helper component is used to add a short callback script to the bottom of a body tag.
8+
/// </summary>
9+
/// <remarks>
10+
/// The callback script is used as a default callback function to submit a form after a reCAPTCHA challenge was successful.
11+
/// </remarks>
12+
public class CallbackScriptTagHelperComponent : TagHelperComponent
13+
{
14+
private readonly string _formId;
15+
16+
/// <summary>
17+
///
18+
/// </summary>
19+
/// <param name="formId"></param>
20+
public CallbackScriptTagHelperComponent(string formId)
21+
{
22+
_formId = formId;
23+
}
24+
25+
/// <inheritdoc />
26+
public override void Process(TagHelperContext context, TagHelperOutput output)
27+
{
28+
if (string.Equals(context.TagName, "body", StringComparison.OrdinalIgnoreCase))
29+
{
30+
output.PostContent.AppendHtml(CallbackScript(_formId));
31+
}
32+
}
33+
34+
private static string CallbackScript(string formId)
35+
{
36+
// Append the formId to the function name in case that multiple recaptcha tags are added in a document.
37+
return $"<script>function submit{formId}(token){{document.getElementById('{formId}').submit();}}</script>";
38+
}
39+
}
40+
}

src/ReCaptcha/TagHelpers/RecaptchaInvisibleTagHelper.cs

Lines changed: 31 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,19 +3,29 @@
33
using Griesoft.AspNetCore.ReCaptcha.Configuration;
44
using Griesoft.AspNetCore.ReCaptcha.Extensions;
55
using Griesoft.AspNetCore.ReCaptcha.Localization;
6+
using Microsoft.AspNetCore.Mvc.Razor.TagHelpers;
67
using Microsoft.AspNetCore.Razor.TagHelpers;
78
using Microsoft.Extensions.Options;
89

910
namespace Griesoft.AspNetCore.ReCaptcha.TagHelpers
1011
{
1112
/// <summary>
12-
/// Add a invisible reCAPTCHA div element to your page or automatically bind the invisible captcha to a button element
13-
/// by adding a re-invisible attribute to it. Both require in addition the callback attribute.
13+
/// Add a invisible reCAPTCHA div element to your page. Or add a 're-invisible' attribute to a button element to bind the invisible captcha to a button.
1414
/// </summary>
15+
/// <remarks>
16+
/// The <see cref="FormId"/> is required. With the exception that you set a <see cref="Callback"/> instead.
17+
/// When setting both the value set to <c>Callback</c> always wins and the <c>FormId</c> value is basically irrelevant.
18+
///
19+
/// For easiest use of this tag helper set only the <c>FormId</c>. This will add a default callback function to the body. That function does
20+
/// submit the form after a successful reCAPTCHA challenge.
21+
/// </remarks>
1522
[HtmlTargetElement("recaptcha-invisible", Attributes = "callback", TagStructure = TagStructure.WithoutEndTag)]
23+
[HtmlTargetElement("recaptcha-invisible", Attributes = "formid", TagStructure = TagStructure.WithoutEndTag)]
1624
[HtmlTargetElement("button", Attributes = "re-invisible,callback")]
25+
[HtmlTargetElement("button", Attributes = "re-invisible,formid")]
1726
public class RecaptchaInvisibleTagHelper : TagHelper
1827
{
28+
private readonly ITagHelperComponentManager _tagHelperComponentManager;
1929
private readonly RecaptchaSettings _settings;
2030
private readonly RecaptchaOptions _options;
2131

@@ -24,14 +34,18 @@ public class RecaptchaInvisibleTagHelper : TagHelper
2434
/// </summary>
2535
/// <param name="settings"></param>
2636
/// <param name="options"></param>
37+
/// <param name="tagHelperComponentManager"></param>
2738
/// <exception cref="ArgumentNullException"></exception>
28-
public RecaptchaInvisibleTagHelper(IOptionsMonitor<RecaptchaSettings> settings, IOptionsMonitor<RecaptchaOptions> options)
39+
public RecaptchaInvisibleTagHelper(IOptionsMonitor<RecaptchaSettings> settings, IOptionsMonitor<RecaptchaOptions> options,
40+
ITagHelperComponentManager tagHelperComponentManager)
2941
{
3042
_ = settings ?? throw new ArgumentNullException(nameof(settings));
3143
_ = options ?? throw new ArgumentNullException(nameof(options));
44+
_ = tagHelperComponentManager ?? throw new ArgumentNullException(nameof(tagHelperComponentManager));
3245

3346
_settings = settings.CurrentValue;
3447
_options = options.CurrentValue;
48+
_tagHelperComponentManager = tagHelperComponentManager;
3549

3650
Badge = _options.Badge;
3751
}
@@ -47,6 +61,12 @@ public RecaptchaInvisibleTagHelper(IOptionsMonitor<RecaptchaSettings> settings,
4761
/// </summary>
4862
public int? TabIndex { get; set; } = null;
4963

64+
/// <summary>
65+
/// The id of the form that will be submitted after a successful reCAPTCHA challenge.
66+
/// This does only apply when not specifying a custom <see cref="Callback"/>.
67+
/// </summary>
68+
public string? FormId { get; set; }
69+
5070
/// <summary>
5171
/// Set the name of your callback function, executed when the user submits a successful response. The "g-recaptcha-response" token is passed to your callback.
5272
/// </summary>
@@ -65,12 +85,12 @@ public RecaptchaInvisibleTagHelper(IOptionsMonitor<RecaptchaSettings> settings,
6585

6686
/// <inheritdoc />
6787
/// <exception cref="ArgumentNullException"></exception>
68-
/// <exception cref="NullReferenceException"><see cref="Callback"/> must not be null. It is required for invisible reCAPTCHA to work.</exception>
88+
/// <exception cref="NullReferenceException">Thrown when both <see cref="Callback"/> and <see cref="FormId"/> are null or empty.</exception>
6989
public override void Process(TagHelperContext context, TagHelperOutput output)
7090
{
7191
_ = output ?? throw new ArgumentNullException(nameof(output));
7292

73-
if (string.IsNullOrEmpty(Callback))
93+
if (string.IsNullOrEmpty(Callback) && string.IsNullOrEmpty(FormId))
7494
{
7595
throw new NullReferenceException(Resources.CallbackPropertyNullErrorMessage);
7696
}
@@ -86,6 +106,12 @@ public override void Process(TagHelperContext context, TagHelperOutput output)
86106
output.Attributes.SetAttribute("data-size", "invisible");
87107
}
88108

109+
if (string.IsNullOrEmpty(Callback))
110+
{
111+
Callback = $"submit{FormId}";
112+
_tagHelperComponentManager.Components.Add(new CallbackScriptTagHelperComponent(FormId!));
113+
}
114+
89115
output.TagMode = TagMode.StartTagAndEndTag;
90116

91117
output.AddClass("g-recaptcha", HtmlEncoder.Default);

tests/ReCaptcha.Tests/TagHelpers/RecaptchaInvisibleTagHelperTests.cs

Lines changed: 65 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
using System.Threading.Tasks;
55
using Griesoft.AspNetCore.ReCaptcha.Configuration;
66
using Griesoft.AspNetCore.ReCaptcha.TagHelpers;
7+
using Microsoft.AspNetCore.Mvc.Razor.TagHelpers;
78
using Microsoft.AspNetCore.Razor.TagHelpers;
89
using Microsoft.Extensions.Options;
910
using Moq;
@@ -19,6 +20,8 @@ public class RecaptchaInvisibleTagHelperTests
1920

2021
private Mock<IOptionsMonitor<RecaptchaSettings>> _settingsMock;
2122
private Mock<IOptionsMonitor<RecaptchaOptions>> _optionsMock;
23+
private Mock<ITagHelperComponentManager> _tagHelperComponentManagerMock;
24+
private Mock<ICollection<ITagHelperComponent>> _tagHelperComponentCollectionMock;
2225
private TagHelperOutput _tagHelperOutputStub;
2326
private TagHelperContext _contextStub;
2427
private RecaptchaInvisibleTagHelper invisibleTagHelper;
@@ -40,6 +43,14 @@ public void Initialize()
4043
.Returns(new RecaptchaOptions())
4144
.Verifiable();
4245

46+
_tagHelperComponentCollectionMock = new Mock<ICollection<ITagHelperComponent>>();
47+
_tagHelperComponentCollectionMock.Setup(instance => instance.Add(It.IsAny<CallbackScriptTagHelperComponent>()))
48+
.Verifiable();
49+
50+
_tagHelperComponentManagerMock = new Mock<ITagHelperComponentManager>();
51+
_tagHelperComponentManagerMock.SetupGet(instance => instance.Components)
52+
.Returns(_tagHelperComponentCollectionMock.Object);
53+
4354
_tagHelperOutputStub = new TagHelperOutput("recaptcha-invisible",
4455
new TagHelperAttributeList(), (useCachedResult, htmlEncoder) =>
4556
{
@@ -52,7 +63,7 @@ public void Initialize()
5263
new Dictionary<object, object>(),
5364
Guid.NewGuid().ToString("N"));
5465

55-
invisibleTagHelper = new RecaptchaInvisibleTagHelper(_settingsMock.Object, _optionsMock.Object)
66+
invisibleTagHelper = new RecaptchaInvisibleTagHelper(_settingsMock.Object, _optionsMock.Object, _tagHelperComponentManagerMock.Object)
5667
{
5768
Callback = CallbackName
5869
};
@@ -73,7 +84,7 @@ public void Construction_IsSuccessful()
7384
.Verifiable();
7485

7586
// Act
76-
var instance = new RecaptchaInvisibleTagHelper(_settingsMock.Object, _optionsMock.Object);
87+
var instance = new RecaptchaInvisibleTagHelper(_settingsMock.Object, _optionsMock.Object, _tagHelperComponentManagerMock.Object);
7788

7889
// Assert
7990
Assert.NotNull(instance);
@@ -91,7 +102,7 @@ public void Constructor_ShouldThrow_WhenSettingsNull()
91102

92103

93104
// Assert
94-
Assert.Throws<ArgumentNullException>(() => new RecaptchaInvisibleTagHelper(null, _optionsMock.Object));
105+
Assert.Throws<ArgumentNullException>(() => new RecaptchaInvisibleTagHelper(null, _optionsMock.Object, _tagHelperComponentManagerMock.Object));
95106
}
96107

97108
[Test]
@@ -104,7 +115,20 @@ public void Constructor_ShouldThrow_WhenOptionsNull()
104115

105116

106117
// Assert
107-
Assert.Throws<ArgumentNullException>(() => new RecaptchaInvisibleTagHelper(_settingsMock.Object, null));
118+
Assert.Throws<ArgumentNullException>(() => new RecaptchaInvisibleTagHelper(_settingsMock.Object, null, _tagHelperComponentManagerMock.Object));
119+
}
120+
121+
[Test]
122+
public void Constructor_ShouldThrow_WhenTagHelperComponentManagerNull()
123+
{
124+
// Arrange
125+
126+
127+
// Act
128+
129+
130+
// Assert
131+
Assert.Throws<ArgumentNullException>(() => new RecaptchaInvisibleTagHelper(_settingsMock.Object, _optionsMock.Object, null));
108132
}
109133

110134
[Test]
@@ -125,7 +149,7 @@ public void Constructor_ShouldSet_DefaultValues_FromGlobalOptions()
125149
.Verifiable();
126150

127151
// Act
128-
var tagHelper = new RecaptchaInvisibleTagHelper(_settingsMock.Object, _optionsMock.Object);
152+
var tagHelper = new RecaptchaInvisibleTagHelper(_settingsMock.Object, _optionsMock.Object, _tagHelperComponentManagerMock.Object);
129153

130154
// Assert
131155
_optionsMock.Verify(options => options.CurrentValue, Times.Once);
@@ -146,16 +170,18 @@ public void Process_ShouldThrow_ArgumentNullException_WhenOutputNull()
146170
}
147171

148172
[Test]
149-
public void Process_ShouldThrow_NullReferenceException_WhenCallbackNullOrEmpty()
173+
public void Process_ShouldThrow_NullReferenceException_WhenCallbackAndFormIdNullOrEmpty()
150174
{
151175
// Arrange
152-
var nullCallbackTagHelper = new RecaptchaInvisibleTagHelper(_settingsMock.Object, _optionsMock.Object)
176+
var nullCallbackTagHelper = new RecaptchaInvisibleTagHelper(_settingsMock.Object, _optionsMock.Object, _tagHelperComponentManagerMock.Object)
153177
{
154-
Callback = null
178+
Callback = null,
179+
FormId = null
155180
};
156-
var emptyCallbackTagHelper = new RecaptchaInvisibleTagHelper(_settingsMock.Object, _optionsMock.Object)
181+
var emptyCallbackTagHelper = new RecaptchaInvisibleTagHelper(_settingsMock.Object, _optionsMock.Object, _tagHelperComponentManagerMock.Object)
157182
{
158-
Callback = string.Empty
183+
Callback = string.Empty,
184+
FormId = string.Empty
159185
};
160186

161187
// Act
@@ -180,6 +206,35 @@ public void Process_ShouldRemove_ReInvisibleAttribute_WhenButtonTag()
180206
Assert.IsFalse(_tagHelperOutputStub.Attributes.ContainsName("re-invisible"));
181207
}
182208

209+
[Test]
210+
public void Process_ShouldSet_CallbackToDefaultCallback_WhenCallbackIsNullOrEmpty()
211+
{
212+
// Arrange
213+
var formId = $"formId";
214+
invisibleTagHelper.Callback = null;
215+
invisibleTagHelper.FormId = formId;
216+
217+
// Act
218+
invisibleTagHelper.Process(_contextStub, _tagHelperOutputStub);
219+
220+
// Assert
221+
Assert.AreEqual(invisibleTagHelper.Callback, $"submit{formId}");
222+
}
223+
224+
[Test]
225+
public void Process_ShouldAdd_CallbackScriptTagHelperComponent_WhenCallbackIsNullOrEmpty()
226+
{
227+
// Arrange
228+
invisibleTagHelper.Callback = null;
229+
invisibleTagHelper.FormId = "formId";
230+
231+
// Act
232+
invisibleTagHelper.Process(_contextStub, _tagHelperOutputStub);
233+
234+
// Assert
235+
_tagHelperComponentCollectionMock.Verify();
236+
}
237+
183238
[Test]
184239
public void Process_ShouldChange_TagNameToDiv_WhenNotButtonTag()
185240
{

0 commit comments

Comments
 (0)