├── .editorconfig ├── .gitattributes ├── .gitignore ├── CHANGELOG.md ├── LICENSE.md ├── README.md ├── ReCaptcha.sln ├── azure-pipelines.yml ├── docs └── Griesoft.AspNetCore.ReCaptcha.xml ├── pr-pipelines.yml ├── src └── ReCaptcha │ ├── BackwardsCompatibility │ └── NullableAttributes.cs │ ├── Client │ └── ProxyHttpClientHandler.cs │ ├── Configuration │ ├── RecaptchaOptions.cs │ ├── RecaptchaServiceConstants.cs │ └── RecaptchaSettings.cs │ ├── Enums │ ├── BadgePosition.cs │ ├── Render.cs │ ├── Size.cs │ ├── Theme.cs │ ├── ValidationError.cs │ └── ValidationFailedAction.cs │ ├── Extensions │ ├── LoggerExtensions.cs │ ├── RecaptchaServiceExtensions.cs │ └── TagHelperOutputExtensions.cs │ ├── Filters │ ├── IRecaptchaValidationFailedResult.cs │ ├── IValidateRecaptchaFilter.cs │ ├── RecaptchaValidationFailedResult.cs │ └── ValidateRecaptchaFilter.cs │ ├── Localization │ ├── Resources.Designer.cs │ └── Resources.resx │ ├── Models │ └── ValidationResponse.cs │ ├── ReCaptcha.csproj │ ├── Services │ ├── IRecaptchaService.cs │ └── RecaptchaService.cs │ ├── TagHelpers │ ├── CallbackScriptTagHelperComponent.cs │ ├── RecaptchaInvisibleTagHelper.cs │ ├── RecaptchaScriptTagHelper.cs │ ├── RecaptchaTagHelper.cs │ └── RecaptchaV3TagHelper.cs │ └── ValidateRecaptchaAttribute.cs └── tests └── ReCaptcha.Tests ├── Client └── ProxyHttpClientHandlerTests.cs ├── Configuration └── RecaptchaOptionsTests.cs ├── Extensions └── RecaptchaServiceExtensionsTests.cs ├── Filters ├── RecaptchaValidationFailedResultTests.cs └── ValidateRecaptchaFilterTests.cs ├── Models └── RecaptchaValidationResponseTests.cs ├── ReCaptcha.Tests.csproj ├── Services └── RecaptchaServiceTests.cs ├── TagHelpers ├── CallbackScriptTagHelperComponentTests.cs ├── RecaptchaInvisibleTagHelperTests.cs ├── RecaptchaScriptTagHelperTests.cs ├── RecaptchaTagHelperTests.cs └── RecaptchaV3TagHelperTests.cs └── ValidateRecaptchaAttributeTests.cs /.editorconfig: -------------------------------------------------------------------------------- 1 | ############################### 2 | # Core EditorConfig Options # 3 | ############################### 4 | 5 | root = true 6 | 7 | # All files 8 | [*] 9 | indent_style = space 10 | 11 | # Code files 12 | [*.{cs,csx,vb,vbx}] 13 | indent_size = 4 14 | insert_final_newline = true 15 | charset = utf-8-bom 16 | 17 | ############################### 18 | # .NET Coding Conventions # 19 | ############################### 20 | 21 | [*.{cs,vb}] 22 | # Organize usings 23 | dotnet_sort_system_directives_first = true 24 | dotnet_separate_import_directive_groups = false 25 | 26 | # this. preferences 27 | dotnet_style_qualification_for_field = false:suggestion 28 | dotnet_style_qualification_for_property = false:suggestion 29 | dotnet_style_qualification_for_method = false:suggestion 30 | dotnet_style_qualification_for_event = false:suggestion 31 | 32 | # Language keywords vs BCL types preferences 33 | dotnet_style_predefined_type_for_locals_parameters_members = true:suggestion 34 | dotnet_style_predefined_type_for_member_access = true:suggestion 35 | 36 | # Parentheses preferences 37 | dotnet_style_parentheses_in_arithmetic_binary_operators = always_for_clarity:warning 38 | 39 | # Modifier preferences 40 | dotnet_style_require_accessibility_modifiers = always:suggestion 41 | dotnet_style_readonly_field = true:suggestion 42 | 43 | # Expression-level preferences 44 | dotnet_style_object_initializer = true:suggestion 45 | dotnet_style_collection_initializer = true:suggestion 46 | dotnet_style_explicit_tuple_names = true:suggestion 47 | dotnet_style_null_propagation = true:suggestion 48 | dotnet_style_coalesce_expression = true:suggestion 49 | dotnet_style_prefer_is_null_check_over_reference_equality_method = true:suggestion 50 | dotnet_style_prefer_inferred_tuple_names = true:suggestion 51 | dotnet_style_prefer_inferred_anonymous_type_member_names = true:suggestion 52 | dotnet_style_prefer_auto_properties = true:suggestion 53 | dotnet_style_prefer_conditional_expression_over_assignment = true:suggestion 54 | dotnet_style_prefer_conditional_expression_over_return = true:suggestion 55 | 56 | ############################### 57 | # C# Code Quality # 58 | ############################### 59 | 60 | # CSharp and Visual Basic code quality settings: 61 | [*.{cs,vb}] 62 | dotnet_code_quality_unused_parameters = all:warning 63 | 64 | ############################### 65 | # Naming Conventions # 66 | ############################### 67 | 68 | # Style Definitions 69 | dotnet_naming_style.pascal_case_style.capitalization = pascal_case 70 | 71 | # Use PascalCase for constant fields 72 | dotnet_naming_rule.constant_fields_should_be_pascal_case.severity = suggestion 73 | dotnet_naming_rule.constant_fields_should_be_pascal_case.symbols = constant_fields 74 | dotnet_naming_rule.constant_fields_should_be_pascal_case.style = pascal_case_style 75 | dotnet_naming_symbols.constant_fields.applicable_kinds = field 76 | dotnet_naming_symbols.constant_fields.applicable_accessibilities = * 77 | dotnet_naming_symbols.constant_fields.required_modifiers = const 78 | 79 | ############################### 80 | # C# Code Style Rules # 81 | ############################### 82 | 83 | [*.cs] 84 | # var preferences 85 | csharp_style_var_for_built_in_types = true:silent 86 | csharp_style_var_when_type_is_apparent = true:suggestion 87 | csharp_style_var_elsewhere = true:suggestion 88 | 89 | # Expression-bodied members 90 | csharp_style_expression_bodied_methods = false:warning 91 | csharp_style_expression_bodied_constructors = false:error 92 | csharp_style_expression_bodied_operators = false:silent 93 | csharp_style_expression_bodied_properties = true:suggestion 94 | csharp_style_expression_bodied_indexers = true:silent 95 | csharp_style_expression_bodied_accessors = true:silent 96 | 97 | # Pattern-matching preferences 98 | csharp_style_pattern_matching_over_is_with_cast_check = true:suggestion 99 | csharp_style_pattern_matching_over_as_with_null_check = true:suggestion 100 | 101 | # Null-checking preferences 102 | csharp_style_throw_expression = true:suggestion 103 | csharp_style_conditional_delegate_call = true:suggestion 104 | 105 | # Code block preferences 106 | csharp_prefer_braces = true:warning 107 | 108 | # Unused value preferences 109 | csharp_style_unused_value_expression_statement_preference = discard_variable:silent 110 | csharp_style_unused_value_assignment_preference = discard_variable:suggestion 111 | 112 | # Modifier preferences 113 | csharp_preferred_modifier_order = public,private,protected,internal,static,extern,new,virtual,abstract,sealed,override,readonly,unsafe,volatile,async:suggestion 114 | 115 | # Expression-level preferences 116 | csharp_style_deconstructed_variable_declaration = true:suggestion 117 | csharp_prefer_simple_default_expression = true:suggestion 118 | csharp_style_pattern_local_over_anonymous_function = true:suggestion 119 | csharp_style_inlined_variable_declaration = true:suggestion 120 | csharp_using_directive_placement = outside_namespace:warning 121 | 122 | ############################### 123 | # C# Formatting Rules # 124 | ############################### 125 | 126 | # New line preferences 127 | csharp_new_line_before_open_brace = all 128 | csharp_new_line_before_else = true 129 | csharp_new_line_before_catch = true 130 | csharp_new_line_before_finally = true 131 | csharp_new_line_before_members_in_object_initializers = true 132 | csharp_new_line_before_members_in_anonymous_types = true 133 | csharp_new_line_between_query_expression_clauses = true:suggestion 134 | 135 | # Indentation preferences 136 | csharp_indent_case_contents = true 137 | csharp_indent_switch_labels = true 138 | csharp_indent_labels = flush_left 139 | 140 | # Space preferences 141 | csharp_space_after_cast = false:suggestion 142 | csharp_space_after_keywords_in_control_flow_statements = true:suggestion 143 | csharp_space_between_method_call_parameter_list_parentheses = false 144 | csharp_space_between_method_declaration_parameter_list_parentheses = false 145 | csharp_space_between_parentheses = false 146 | csharp_space_before_colon_in_inheritance_clause = true 147 | csharp_space_after_colon_in_inheritance_clause = true 148 | csharp_space_around_binary_operators = before_and_after 149 | csharp_space_between_method_declaration_empty_parameter_list_parentheses = false 150 | csharp_space_between_method_call_name_and_opening_parenthesis = false 151 | csharp_space_between_method_call_empty_parameter_list_parentheses = false 152 | csharp_space_after_comma = true 153 | csharp_space_after_dot = false 154 | 155 | # Wrapping preferences 156 | csharp_preserve_single_line_statements = true 157 | csharp_preserve_single_line_blocks = true 158 | 159 | # CA1707: Identifiers should not contain underscores 160 | dotnet_diagnostic.CA1707.severity = none 161 | 162 | # CA1812: RecaptchaService is an internal class that is apparently never instantiated. If so, remove the code from the assembly. If this class is intended to contain only static members, make it static (Shared in Visual Basic). 163 | dotnet_diagnostic.CA1812.severity = suggestion 164 | 165 | # CA1308: Normalize strings to uppercase 166 | dotnet_diagnostic.CA1308.severity = none 167 | 168 | # CA2234: Pass system uri objects instead of strings 169 | dotnet_diagnostic.CA2234.severity = silent 170 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | ############################################################################### 2 | # Set default behavior to automatically normalize line endings. 3 | ############################################################################### 4 | * text=auto 5 | 6 | ############################################################################### 7 | # Set default behavior for command prompt diff. 8 | # 9 | # This is need for earlier builds of msysgit that does not have it on by 10 | # default for csharp files. 11 | # Note: This is only used by command line 12 | ############################################################################### 13 | #*.cs diff=csharp 14 | 15 | ############################################################################### 16 | # Set the merge driver for project and solution files 17 | # 18 | # Merging from the command prompt will add diff markers to the files if there 19 | # are conflicts (Merging from VS is not affected by the settings below, in VS 20 | # the diff markers are never inserted). Diff markers may cause the following 21 | # file extensions to fail to load in VS. An alternative would be to treat 22 | # these files as binary and thus will always conflict and require user 23 | # intervention with every merge. To do so, just uncomment the entries below 24 | ############################################################################### 25 | #*.sln merge=binary 26 | #*.csproj merge=binary 27 | #*.vbproj merge=binary 28 | #*.vcxproj merge=binary 29 | #*.vcproj merge=binary 30 | #*.dbproj merge=binary 31 | #*.fsproj merge=binary 32 | #*.lsproj merge=binary 33 | #*.wixproj merge=binary 34 | #*.modelproj merge=binary 35 | #*.sqlproj merge=binary 36 | #*.wwaproj merge=binary 37 | 38 | ############################################################################### 39 | # behavior for image files 40 | # 41 | # image files are treated as binary by default. 42 | ############################################################################### 43 | #*.jpg binary 44 | #*.png binary 45 | #*.gif binary 46 | 47 | ############################################################################### 48 | # diff behavior for common document formats 49 | # 50 | # Convert binary document formats to text before diffing them. This feature 51 | # is only available from the command line. Turn it on by uncommenting the 52 | # entries below. 53 | ############################################################################### 54 | #*.doc diff=astextplain 55 | #*.DOC diff=astextplain 56 | #*.docx diff=astextplain 57 | #*.DOCX diff=astextplain 58 | #*.dot diff=astextplain 59 | #*.DOT diff=astextplain 60 | #*.pdf diff=astextplain 61 | #*.PDF diff=astextplain 62 | #*.rtf diff=astextplain 63 | #*.RTF diff=astextplain 64 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ## Ignore Visual Studio temporary files, build results, and 2 | ## files generated by popular Visual Studio add-ons. 3 | ## 4 | ## Get latest from https://github.com/github/gitignore/blob/master/VisualStudio.gitignore 5 | 6 | # User-specific files 7 | *.rsuser 8 | *.suo 9 | *.user 10 | *.userosscache 11 | *.sln.docstates 12 | 13 | # User-specific files (MonoDevelop/Xamarin Studio) 14 | *.userprefs 15 | 16 | # Build results 17 | [Dd]ebug/ 18 | [Dd]ebugPublic/ 19 | [Rr]elease/ 20 | [Rr]eleases/ 21 | x64/ 22 | x86/ 23 | [Aa][Rr][Mm]/ 24 | [Aa][Rr][Mm]64/ 25 | bld/ 26 | [Bb]in/ 27 | [Oo]bj/ 28 | [Ll]og/ 29 | 30 | # Visual Studio 2015/2017 cache/options directory 31 | .vs/ 32 | # Uncomment if you have tasks that create the project's static files in wwwroot 33 | #wwwroot/ 34 | 35 | # Visual Studio 2017 auto generated files 36 | Generated\ Files/ 37 | 38 | # MSTest test Results 39 | [Tt]est[Rr]esult*/ 40 | [Bb]uild[Ll]og.* 41 | 42 | # NUNIT 43 | *.VisualState.xml 44 | TestResult.xml 45 | 46 | # Build Results of an ATL Project 47 | [Dd]ebugPS/ 48 | [Rr]eleasePS/ 49 | dlldata.c 50 | 51 | # Benchmark Results 52 | BenchmarkDotNet.Artifacts/ 53 | 54 | # .NET Core 55 | project.lock.json 56 | project.fragment.lock.json 57 | artifacts/ 58 | 59 | # StyleCop 60 | StyleCopReport.xml 61 | 62 | # Files built by Visual Studio 63 | *_i.c 64 | *_p.c 65 | *_h.h 66 | *.ilk 67 | *.meta 68 | *.obj 69 | *.iobj 70 | *.pch 71 | *.pdb 72 | *.ipdb 73 | *.pgc 74 | *.pgd 75 | *.rsp 76 | *.sbr 77 | *.tlb 78 | *.tli 79 | *.tlh 80 | *.tmp 81 | *.tmp_proj 82 | *_wpftmp.csproj 83 | *.log 84 | *.vspscc 85 | *.vssscc 86 | .builds 87 | *.pidb 88 | *.svclog 89 | *.scc 90 | 91 | # Chutzpah Test files 92 | _Chutzpah* 93 | 94 | # Visual C++ cache files 95 | ipch/ 96 | *.aps 97 | *.ncb 98 | *.opendb 99 | *.opensdf 100 | *.sdf 101 | *.cachefile 102 | *.VC.db 103 | *.VC.VC.opendb 104 | 105 | # Visual Studio profiler 106 | *.psess 107 | *.vsp 108 | *.vspx 109 | *.sap 110 | 111 | # Visual Studio Trace Files 112 | *.e2e 113 | 114 | # TFS 2012 Local Workspace 115 | $tf/ 116 | 117 | # Guidance Automation Toolkit 118 | *.gpState 119 | 120 | # ReSharper is a .NET coding add-in 121 | _ReSharper*/ 122 | *.[Rr]e[Ss]harper 123 | *.DotSettings.user 124 | 125 | # JustCode is a .NET coding add-in 126 | .JustCode 127 | 128 | # TeamCity is a build add-in 129 | _TeamCity* 130 | 131 | # DotCover is a Code Coverage Tool 132 | *.dotCover 133 | 134 | # AxoCover is a Code Coverage Tool 135 | .axoCover/* 136 | !.axoCover/settings.json 137 | 138 | # Visual Studio code coverage results 139 | *.coverage 140 | *.coveragexml 141 | 142 | # NCrunch 143 | _NCrunch_* 144 | .*crunch*.local.xml 145 | nCrunchTemp_* 146 | 147 | # MightyMoose 148 | *.mm.* 149 | AutoTest.Net/ 150 | 151 | # Web workbench (sass) 152 | .sass-cache/ 153 | 154 | # Installshield output folder 155 | [Ee]xpress/ 156 | 157 | # DocProject is a documentation generator add-in 158 | DocProject/buildhelp/ 159 | DocProject/Help/*.HxT 160 | DocProject/Help/*.HxC 161 | DocProject/Help/*.hhc 162 | DocProject/Help/*.hhk 163 | DocProject/Help/*.hhp 164 | DocProject/Help/Html2 165 | DocProject/Help/html 166 | 167 | # Click-Once directory 168 | publish/ 169 | 170 | # Publish Web Output 171 | *.[Pp]ublish.xml 172 | *.azurePubxml 173 | # Note: Comment the next line if you want to checkin your web deploy settings, 174 | # but database connection strings (with potential passwords) will be unencrypted 175 | *.pubxml 176 | *.publishproj 177 | 178 | # Microsoft Azure Web App publish settings. Comment the next line if you want to 179 | # checkin your Azure Web App publish settings, but sensitive information contained 180 | # in these scripts will be unencrypted 181 | PublishScripts/ 182 | 183 | # NuGet Packages 184 | *.nupkg 185 | # The packages folder can be ignored because of Package Restore 186 | **/[Pp]ackages/* 187 | # except build/, which is used as an MSBuild target. 188 | !**/[Pp]ackages/build/ 189 | # Uncomment if necessary however generally it will be regenerated when needed 190 | #!**/[Pp]ackages/repositories.config 191 | # NuGet v3's project.json files produces more ignorable files 192 | *.nuget.props 193 | *.nuget.targets 194 | 195 | # Microsoft Azure Build Output 196 | csx/ 197 | *.build.csdef 198 | 199 | # Microsoft Azure Emulator 200 | ecf/ 201 | rcf/ 202 | 203 | # Windows Store app package directories and files 204 | AppPackages/ 205 | BundleArtifacts/ 206 | Package.StoreAssociation.xml 207 | _pkginfo.txt 208 | *.appx 209 | 210 | # Visual Studio cache files 211 | # files ending in .cache can be ignored 212 | *.[Cc]ache 213 | # but keep track of directories ending in .cache 214 | !?*.[Cc]ache/ 215 | 216 | # Others 217 | ClientBin/ 218 | ~$* 219 | *~ 220 | *.dbmdl 221 | *.dbproj.schemaview 222 | *.jfm 223 | *.pfx 224 | *.publishsettings 225 | orleans.codegen.cs 226 | 227 | # Including strong name files can present a security risk 228 | # (https://github.com/github/gitignore/pull/2483#issue-259490424) 229 | #*.snk 230 | 231 | # Since there are multiple workflows, uncomment next line to ignore bower_components 232 | # (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) 233 | #bower_components/ 234 | 235 | # RIA/Silverlight projects 236 | Generated_Code/ 237 | 238 | # Backup & report files from converting an old project file 239 | # to a newer Visual Studio version. Backup files are not needed, 240 | # because we have git ;-) 241 | _UpgradeReport_Files/ 242 | Backup*/ 243 | UpgradeLog*.XML 244 | UpgradeLog*.htm 245 | ServiceFabricBackup/ 246 | *.rptproj.bak 247 | 248 | # SQL Server files 249 | *.mdf 250 | *.ldf 251 | *.ndf 252 | 253 | # Business Intelligence projects 254 | *.rdl.data 255 | *.bim.layout 256 | *.bim_*.settings 257 | *.rptproj.rsuser 258 | *- Backup*.rdl 259 | 260 | # Microsoft Fakes 261 | FakesAssemblies/ 262 | 263 | # GhostDoc plugin setting file 264 | *.GhostDoc.xml 265 | 266 | # Node.js Tools for Visual Studio 267 | .ntvs_analysis.dat 268 | node_modules/ 269 | 270 | # Visual Studio 6 build log 271 | *.plg 272 | 273 | # Visual Studio 6 workspace options file 274 | *.opt 275 | 276 | # Visual Studio 6 auto-generated workspace file (contains which files were open etc.) 277 | *.vbw 278 | 279 | # Visual Studio LightSwitch build output 280 | **/*.HTMLClient/GeneratedArtifacts 281 | **/*.DesktopClient/GeneratedArtifacts 282 | **/*.DesktopClient/ModelManifest.xml 283 | **/*.Server/GeneratedArtifacts 284 | **/*.Server/ModelManifest.xml 285 | _Pvt_Extensions 286 | 287 | # Paket dependency manager 288 | .paket/paket.exe 289 | paket-files/ 290 | 291 | # FAKE - F# Make 292 | .fake/ 293 | 294 | # JetBrains Rider 295 | .idea/ 296 | *.sln.iml 297 | 298 | # CodeRush personal settings 299 | .cr/personal 300 | 301 | # Python Tools for Visual Studio (PTVS) 302 | __pycache__/ 303 | *.pyc 304 | 305 | # Cake - Uncomment if you are using it 306 | # tools/** 307 | # !tools/packages.config 308 | 309 | # Tabs Studio 310 | *.tss 311 | 312 | # Telerik's JustMock configuration file 313 | *.jmconfig 314 | 315 | # BizTalk build output 316 | *.btp.cs 317 | *.btm.cs 318 | *.odx.cs 319 | *.xsd.cs 320 | 321 | # OpenCover UI analysis results 322 | OpenCover/ 323 | 324 | # Azure Stream Analytics local run output 325 | ASALocalRun/ 326 | 327 | # MSBuild Binary and Structured Log 328 | *.binlog 329 | 330 | # NVidia Nsight GPU debugger configuration file 331 | *.nvuser 332 | 333 | # MFractors (Xamarin productivity tool) working folder 334 | .mfractor/ 335 | 336 | # Local History for Visual Studio 337 | .localhistory/ 338 | 339 | # BeatPulse healthcheck temp database 340 | healthchecksdb -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # [2.2.1](https://github.com/griesoft/aspnetcore-recaptcha/releases/tag/v2.2.1) (25.09.2022) 2 | 3 | ### Added: 4 | - Support forward proxy #19 ([mikocot](https://github.com/mikocot)) 5 | 6 | ### New Contributor: 7 | [mikocot](https://github.com/mikocot) 8 | 9 | # [2.0.0](https://github.com/griesoft/aspnetcore-recaptcha/releases/tag/v2.0.1) (22.04.2022) 10 | 11 | ### Added: 12 | - An Action property to the ValidateRecaptchaFilter and the ValidateRecaptchaAttribute, for reCAPTCHA V3 action validation. 13 | - A default callback script, which is added to the bottom of the body when automatically binding the challeng to an element. 14 | - New FormId property to RecaptchaInvisibleTagHelper, which should be set when automatically binding to a challenge. 15 | - A new RecaptchaV3TagHelper for automatic binding of V3 challenges. 16 | - Added NETCOREAPP3.1, NET5 and NET6 as target frameworks. 17 | 18 | ### Updated: 19 | - XML documentation was updated for some classes, methods and properties. 20 | - The RecaptchaInvisibleTagHelper now supports automatic binding to the challenge. 21 | - The RecaptchaScriptTagHelper now fully supports reCAPTCHA V3, so you may now make use of automatic or explicit rendering. 22 | 23 | ### Removed: 24 | - Dropped NETCOREAPP2.1 and NETCOREAPP3.0 from target framworks. 25 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Griesinger Software 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ASP.NET Core reCAPTCHA 2 | A Google reCAPTCHA service for ASP.NET Core. Keep bots away from submitting forms or other actions in just a few steps. 3 | 4 | The service 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. 5 | 6 | [![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) 7 | [![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) 8 | [![License](https://badgen.net/github/license/griesoft/aspnetcore-recaptcha)](https://github.com/griesoft/aspnetcore-recaptcha/blob/master/LICENSE) 9 | [![NuGet](https://badgen.net/nuget/v/Griesoft.AspNetCore.ReCaptcha)](https://www.nuget.org/packages/Griesoft.AspNetCore.ReCaptcha) 10 | [![GitHub Release](https://badgen.net/github/release/griesoft/aspnetcore-recaptcha)](https://github.com/griesoft/aspnetcore-recaptcha/releases) 11 | 12 | ## Installation 13 | 14 | Install via [NuGet](https://www.nuget.org/packages/Griesoft.AspNetCore.ReCaptcha/) using: 15 | 16 | `PM> Install-Package Griesoft.AspNetCore.ReCaptcha` 17 | 18 | ## Quickstart 19 | 20 | ### Prequisites 21 | 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). 22 | 23 | After sign-up, you should have a **Site key** and a **Secret key**. You will need those to configure the service in your app. 24 | 25 | ### Configuration 26 | 27 | #### Settings 28 | 29 | Open your `appsettings.json` and add the following lines: 30 | 31 | ```json 32 | "RecaptchaSettings": { 33 | "SiteKey": "", 34 | "SecretKey": "" 35 | } 36 | ``` 37 | **Important:** The `SiteKey` will be exposed to the public, so make sure you don't accidentally swap it with the `SecretKey`. 38 | 39 | #### Service Registration 40 | 41 | Register this service by calling the `AddRecaptchaService()` method which is an extension method of `IServiceCollection`. For example: 42 | 43 | ##### .NET 6 44 | 45 | ```csharp 46 | var builder = WebApplication.CreateBuilder(args); 47 | 48 | builder.Services.AddRecaptchaService(); 49 | ``` 50 | 51 | ##### Prior to .NET 6 52 | 53 | ```csharp 54 | public void ConfigureServices(IServiceCollection services) 55 | { 56 | services.AddRecaptchaService(); 57 | } 58 | ``` 59 | 60 | ### Adding a reCAPTCHA element to your view 61 | 62 | First, import the tag helpers. Open your `_ViewImports.cshtml` file and add the following lines: 63 | 64 | ```razor 65 | @using Griesoft.AspNetCore.ReCaptcha 66 | @addTagHelper *, Griesoft.AspNetCore.ReCaptcha 67 | ``` 68 | 69 | Next, you need to add the `` 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. 70 | 71 | Now you may add a reCAPTCHA challenge to your view where ever you need it. Using the `` tag in your form will render a reCAPTCHA V2 checkbox inside it. 72 | 73 | For invisible reCAPTCHA use: 74 | ```html 75 | 76 | ``` 77 | 78 | For reCAPTCHA V3 use: 79 | ```html 80 | Submit 81 | ``` 82 | 83 | ### Adding backend validation to an action 84 | 85 | Validation is done by decorating your controller or action with `[ValidateRecaptcha]`. 86 | 87 | For example: 88 | 89 | ```csharp 90 | using Griesoft.AspNetCore.ReCaptcha; 91 | using Microsoft.AspNetCore.Mvc; 92 | 93 | namespace ReCaptcha.Sample.Controllers 94 | { 95 | public class ExampleController : Controller 96 | { 97 | [ValidateRecaptcha] 98 | public IActionResult FormSubmit(SomeModel model) 99 | { 100 | // Will hit the next line only if validation was successful 101 | return View("FormSubmitSuccessView"); 102 | } 103 | } 104 | } 105 | ``` 106 | Now each incoming request to that action will be validated for a valid reCAPTCHA token. 107 | 108 | 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. 109 | 110 | This can be achieved like this: 111 | 112 | ```csharp 113 | [ValidateRecaptcha(ValidationFailedAction = ValidationFailedAction.ContinueRequest)] 114 | public IActionResult FormSubmit(SomeModel model, ValidationResponse recaptchaResponse) 115 | { 116 | if (!recaptchaResponse.Success) 117 | { 118 | return BadRequest(); 119 | } 120 | 121 | return View("FormSubmitSuccessView"); 122 | } 123 | ``` 124 | 125 | In case you are validating a reCAPTCHA V3 token, make sure you also add an action name to your validator. 126 | 127 | For example: 128 | 129 | ```csharp 130 | [ValidateRecaptcha(Action = "submit")] 131 | public IActionResult FormSubmit(SomeModel model) 132 | { 133 | return View("FormSubmitSuccessView"); 134 | } 135 | ``` 136 | 137 | ## Options & Customization 138 | 139 | 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. 140 | 141 | All options from the [official reCAPTCHA docs](https://developers.google.com/recaptcha/intro) are available to you in this package. 142 | 143 | ## Proxy Server 144 | 145 | If your environment requires to use forward proxy server, this can be done by specifying two additional parameters in the configuration file 146 | 147 | ```json 148 | "RecaptchaSettings": { 149 | "UseProxy": true, 150 | "ProxyAddress": "http://10.1.2.3:80" 151 | } 152 | ``` 153 | 154 | The address should contain port and protocol, as required by the System.Net.WebProxy class. 155 | ## Detailed Documentation 156 | Is on it's way... 157 | 158 | ## Contributing 159 | 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. 😄 160 | -------------------------------------------------------------------------------- /ReCaptcha.sln: -------------------------------------------------------------------------------- 1 |  2 | Microsoft Visual Studio Solution File, Format Version 12.00 3 | # Visual Studio Version 17 4 | VisualStudioVersion = 17.1.32210.238 5 | MinimumVisualStudioVersion = 10.0.40219.1 6 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "ReCaptcha", "src\ReCaptcha\ReCaptcha.csproj", "{0553A2AB-29DC-4328-9F26-3537597C3C23}" 7 | EndProject 8 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "ReCaptcha.Tests", "tests\ReCaptcha.Tests\ReCaptcha.Tests.csproj", "{31DBD848-6012-4AA7-817B-7ED20DF6AF83}" 9 | EndProject 10 | Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{3A037F16-5150-43AA-80BD-872D99F3F94B}" 11 | ProjectSection(SolutionItems) = preProject 12 | .editorconfig = .editorconfig 13 | azure-pipelines.yml = azure-pipelines.yml 14 | CHANGELOG.md = CHANGELOG.md 15 | .github\FUNDING.yml = .github\FUNDING.yml 16 | LICENSE.md = LICENSE.md 17 | pr-pipelines.yml = pr-pipelines.yml 18 | README.md = README.md 19 | EndProjectSection 20 | EndProject 21 | Global 22 | GlobalSection(SolutionConfigurationPlatforms) = preSolution 23 | Debug|Any CPU = Debug|Any CPU 24 | Release|Any CPU = Release|Any CPU 25 | EndGlobalSection 26 | GlobalSection(ProjectConfigurationPlatforms) = postSolution 27 | {0553A2AB-29DC-4328-9F26-3537597C3C23}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 28 | {0553A2AB-29DC-4328-9F26-3537597C3C23}.Debug|Any CPU.Build.0 = Debug|Any CPU 29 | {0553A2AB-29DC-4328-9F26-3537597C3C23}.Release|Any CPU.ActiveCfg = Release|Any CPU 30 | {0553A2AB-29DC-4328-9F26-3537597C3C23}.Release|Any CPU.Build.0 = Release|Any CPU 31 | {31DBD848-6012-4AA7-817B-7ED20DF6AF83}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 32 | {31DBD848-6012-4AA7-817B-7ED20DF6AF83}.Debug|Any CPU.Build.0 = Debug|Any CPU 33 | {31DBD848-6012-4AA7-817B-7ED20DF6AF83}.Release|Any CPU.ActiveCfg = Release|Any CPU 34 | {31DBD848-6012-4AA7-817B-7ED20DF6AF83}.Release|Any CPU.Build.0 = Release|Any CPU 35 | EndGlobalSection 36 | GlobalSection(SolutionProperties) = preSolution 37 | HideSolutionNode = FALSE 38 | EndGlobalSection 39 | GlobalSection(ExtensibilityGlobals) = postSolution 40 | SolutionGuid = {21D1F518-0B82-49BB-BE11-6D8BE811EE8A} 41 | EndGlobalSection 42 | EndGlobal 43 | -------------------------------------------------------------------------------- /azure-pipelines.yml: -------------------------------------------------------------------------------- 1 | trigger: 2 | branches: 3 | include: 4 | - dev 5 | - master 6 | paths: 7 | exclude: 8 | - '*/README.md' 9 | - '*/.github/*' 10 | - '*/LICENSE.md' 11 | - '*/CHANGELOG.md' 12 | 13 | pr: none 14 | 15 | 16 | pool: 17 | vmImage: 'windows-latest' 18 | 19 | variables: 20 | - group: 'Package Versioning' 21 | - name: buildPlatform 22 | value: 'Any CPU' 23 | - name: buildConfiguration 24 | value: 'Release' 25 | - name: buildDirPath 26 | value: '$(Build.SourcesDirectory)\src\ReCaptcha\bin\$(buildPlatform)\$(buildConfiguration)' 27 | 28 | 29 | jobs: 30 | - job: Init 31 | displayName: Initialize & Versioning 32 | steps: 33 | 34 | - powershell: | 35 | if('$(Build.SourceBranch)' -eq 'refs/heads/master') 36 | { 37 | Write-Host "##vso[task.setvariable variable=version;isOutput=true]$(major).$(minor).$(patch)" 38 | } 39 | else 40 | { 41 | Write-Host "##vso[task.setvariable variable=version;isOutput=true]$(major).$(minor).$(patch)-ci$(Build.BuildId)" 42 | } 43 | displayName: Set Package Version 44 | name: package 45 | 46 | - job: BuildPack 47 | displayName: Build & Pack 48 | dependsOn: Init 49 | variables: 50 | - name: packageVersion 51 | value: $[ dependencies.Init.outputs['package.version'] ] 52 | steps: 53 | - task: MSBuild@1 54 | displayName: Build & Pack Project 55 | inputs: 56 | solution: '**/src/ReCaptcha/ReCaptcha.csproj' 57 | configuration: $(buildConfiguration) 58 | platform: $(buildPlatform) 59 | msbuildArguments: '/restore /t:Build /t:Pack /p:ContinuousIntegrationBuild=true /p:Deterministic=false /p:PackageVersion=$(packageVersion) /p:PackageOutputPath="$(buildDirPath)"' 60 | 61 | - task: PublishBuildArtifacts@1 62 | displayName: Publish Build Artifacts 63 | inputs: 64 | PathtoPublish: '$(buildDirPath)' 65 | ArtifactName: 'nuget' 66 | publishLocation: 'Container' 67 | 68 | - job: Test 69 | displayName: Run Unit Tests 70 | dependsOn: Init 71 | steps: 72 | - task: DotNetCoreCLI@2 73 | displayName: Run Tests 74 | inputs: 75 | command: 'test' 76 | publishTestResults: true 77 | projects: '**/tests/ReCaptcha.Tests/ReCaptcha.Tests.csproj' 78 | testRunTitle: 'Project Unit Tests' 79 | workingDirectory: '$(System.DefaultWorkingDirectory)' 80 | 81 | - deployment: PushToTestFeed 82 | displayName: Push to Development Feed 83 | dependsOn: 84 | - BuildPack 85 | - Test 86 | condition: and(succeeded(), ne(variables['Build.SourceBranch'], 'refs/heads/master')) 87 | environment: Development 88 | strategy: 89 | runOnce: 90 | deploy: 91 | steps: 92 | - download: current 93 | artifact: nuget 94 | 95 | - task: NuGetCommand@2 96 | inputs: 97 | command: 'push' 98 | packagesToPush: '$(Pipeline.Workspace)/**/*.nupkg;!$(Pipeline.Workspace)/**/*.snupkg' 99 | nuGetFeedType: 'internal' 100 | publishVstsFeed: 'f9036ec9-eb1c-4aff-a2b8-27fdaa573d0f/29b705d0-eac8-42a7-9230-4bcfe9f83688' 101 | allowPackageConflicts: true -------------------------------------------------------------------------------- /pr-pipelines.yml: -------------------------------------------------------------------------------- 1 | trigger: none 2 | pr: 3 | branches: 4 | include: 5 | - master 6 | 7 | 8 | pool: 9 | vmImage: 'windows-latest' 10 | 11 | variables: 12 | - group: 'Package Versioning' 13 | - name: buildPlatform 14 | value: 'Any CPU' 15 | - name: buildConfiguration 16 | value: 'Release' 17 | 18 | jobs: 19 | - job: BuildPack 20 | displayName: Build & Pack 21 | variables: 22 | - name: packageVersion 23 | value: "$(major).$(minor).$(patch)" 24 | steps: 25 | - task: MSBuild@1 26 | displayName: Build & Pack Project 27 | inputs: 28 | solution: '**/src/ReCaptcha/ReCaptcha.csproj' 29 | configuration: $(buildConfiguration) 30 | platform: $(buildPlatform) 31 | msbuildArguments: '/restore /t:Build /p:ContinuousIntegrationBuild=true /p:Deterministic=false /p:PackageVersion=$(packageVersion)' 32 | 33 | - job: Test 34 | displayName: Run Unit Tests 35 | steps: 36 | - task: DotNetCoreCLI@2 37 | displayName: Run Tests 38 | inputs: 39 | command: 'test' 40 | publishTestResults: true 41 | projects: '**/tests/ReCaptcha.Tests/ReCaptcha.Tests.csproj' 42 | testRunTitle: 'Project Unit Tests' 43 | workingDirectory: '$(System.DefaultWorkingDirectory)' -------------------------------------------------------------------------------- /src/ReCaptcha/BackwardsCompatibility/NullableAttributes.cs: -------------------------------------------------------------------------------- 1 | #define INTERNAL_NULLABLE_ATTRIBUTES 2 | #if NET462 3 | 4 | // Licensed to the .NET Foundation under one or more agreements. 5 | // The .NET Foundation licenses this file to you under the MIT license. 6 | // See the LICENSE file in the project root for more information. 7 | 8 | // Code copied from https://github.com/dotnet/corefx/blob/48363ac826ccf66fbe31a5dcb1dc2aab9a7dd768/src/Common/src/CoreLib/System/Diagnostics/CodeAnalysis/NullableAttributes.cs 9 | 10 | #pragma warning disable IDE0021 // Use block body for constructors 11 | namespace System.Diagnostics.CodeAnalysis 12 | { 13 | /// Specifies that null is allowed as an input even if the corresponding type disallows it. 14 | [AttributeUsage(AttributeTargets.Field | AttributeTargets.Parameter | AttributeTargets.Property, Inherited = false)] 15 | #if INTERNAL_NULLABLE_ATTRIBUTES 16 | internal 17 | #else 18 | public 19 | #endif 20 | sealed class AllowNullAttribute : Attribute 21 | { } 22 | 23 | /// Specifies that null is disallowed as an input even if the corresponding type allows it. 24 | [AttributeUsage(AttributeTargets.Field | AttributeTargets.Parameter | AttributeTargets.Property, Inherited = false)] 25 | #if INTERNAL_NULLABLE_ATTRIBUTES 26 | internal 27 | #else 28 | public 29 | #endif 30 | sealed class DisallowNullAttribute : Attribute 31 | { } 32 | 33 | /// Specifies that an output may be null even if the corresponding type disallows it. 34 | [AttributeUsage(AttributeTargets.Field | AttributeTargets.Parameter | AttributeTargets.Property | AttributeTargets.ReturnValue, Inherited = false)] 35 | #if INTERNAL_NULLABLE_ATTRIBUTES 36 | internal 37 | #else 38 | public 39 | #endif 40 | sealed class MaybeNullAttribute : Attribute 41 | { } 42 | 43 | /// Specifies that an output will not be null even if the corresponding type allows it. 44 | [AttributeUsage(AttributeTargets.Field | AttributeTargets.Parameter | AttributeTargets.Property | AttributeTargets.ReturnValue, Inherited = false)] 45 | #if INTERNAL_NULLABLE_ATTRIBUTES 46 | internal 47 | #else 48 | public 49 | #endif 50 | sealed class NotNullAttribute : Attribute 51 | { } 52 | 53 | /// Specifies that when a method returns , the parameter may be null even if the corresponding type disallows it. 54 | [AttributeUsage(AttributeTargets.Parameter, Inherited = false)] 55 | #if INTERNAL_NULLABLE_ATTRIBUTES 56 | internal 57 | #else 58 | public 59 | #endif 60 | sealed class MaybeNullWhenAttribute : Attribute 61 | { 62 | /// Initializes the attribute with the specified return value condition. 63 | /// 64 | /// The return value condition. If the method returns this value, the associated parameter may be null. 65 | /// 66 | public MaybeNullWhenAttribute(bool returnValue) => ReturnValue = returnValue; 67 | 68 | /// Gets the return value condition. 69 | public bool ReturnValue { get; } 70 | } 71 | 72 | /// Specifies that when a method returns , the parameter will not be null even if the corresponding type allows it. 73 | [AttributeUsage(AttributeTargets.Parameter, Inherited = false)] 74 | #if INTERNAL_NULLABLE_ATTRIBUTES 75 | internal 76 | #else 77 | public 78 | #endif 79 | sealed class NotNullWhenAttribute : Attribute 80 | { 81 | /// Initializes the attribute with the specified return value condition. 82 | /// 83 | /// The return value condition. If the method returns this value, the associated parameter will not be null. 84 | /// 85 | public NotNullWhenAttribute(bool returnValue) => ReturnValue = returnValue; 86 | 87 | /// Gets the return value condition. 88 | public bool ReturnValue { get; } 89 | } 90 | 91 | /// Specifies that the output will be non-null if the named parameter is non-null. 92 | [AttributeUsage(AttributeTargets.Parameter | AttributeTargets.Property | AttributeTargets.ReturnValue, AllowMultiple = true, Inherited = false)] 93 | #if INTERNAL_NULLABLE_ATTRIBUTES 94 | internal 95 | #else 96 | public 97 | #endif 98 | sealed class NotNullIfNotNullAttribute : Attribute 99 | { 100 | /// Initializes the attribute with the associated parameter name. 101 | /// 102 | /// The associated parameter name. The output will be non-null if the argument to the parameter specified is non-null. 103 | /// 104 | public NotNullIfNotNullAttribute(string parameterName) => ParameterName = parameterName; 105 | 106 | /// Gets the associated parameter name. 107 | public string ParameterName { get; } 108 | } 109 | 110 | /// Applied to a method that will never return under any circumstance. 111 | [AttributeUsage(AttributeTargets.Method, Inherited = false)] 112 | #if INTERNAL_NULLABLE_ATTRIBUTES 113 | internal 114 | #else 115 | public 116 | #endif 117 | sealed class DoesNotReturnAttribute : Attribute 118 | { } 119 | 120 | /// Specifies that the method will not return if the associated Boolean parameter is passed the specified value. 121 | [AttributeUsage(AttributeTargets.Parameter, Inherited = false)] 122 | #if INTERNAL_NULLABLE_ATTRIBUTES 123 | internal 124 | #else 125 | public 126 | #endif 127 | sealed class DoesNotReturnIfAttribute : Attribute 128 | { 129 | /// Initializes the attribute with the specified parameter value. 130 | /// 131 | /// The condition parameter value. Code after the method will be considered unreachable by diagnostics if the argument to 132 | /// the associated parameter matches this value. 133 | /// 134 | public DoesNotReturnIfAttribute(bool parameterValue) => ParameterValue = parameterValue; 135 | 136 | /// Gets the condition parameter value. 137 | public bool ParameterValue { get; } 138 | } 139 | } 140 | #pragma warning restore IDE0021 // Use block body for constructors 141 | #endif 142 | -------------------------------------------------------------------------------- /src/ReCaptcha/Client/ProxyHttpClientHandler.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Net; 4 | using System.Net.Http; 5 | using System.Text; 6 | using Griesoft.AspNetCore.ReCaptcha.Configuration; 7 | using Microsoft.Extensions.Options; 8 | 9 | namespace Griesoft.AspNetCore.ReCaptcha.Client 10 | { 11 | 12 | /// 13 | /// HttpClientHandler utilizing proxy server if such configuration was provided 14 | /// 15 | public class ProxyHttpClientHandler : HttpClientHandler 16 | { 17 | /// 18 | /// Create HttpHandler configured by RecaptchaSettings 19 | /// 20 | /// Recaptcha Settings sepcifying proxy configuration 21 | public ProxyHttpClientHandler(IOptions settings) 22 | { 23 | var currentSettings = settings?.Value; 24 | 25 | if (currentSettings != null && currentSettings.UseProxy == true && !String.IsNullOrEmpty(currentSettings.ProxyAddress)) 26 | { 27 | this.UseProxy = true; 28 | this.Proxy = new WebProxy(currentSettings.ProxyAddress, currentSettings.BypassOnLocal); 29 | } 30 | else 31 | { 32 | this.UseProxy = false; 33 | } 34 | } 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/ReCaptcha/Configuration/RecaptchaOptions.cs: -------------------------------------------------------------------------------- 1 | using Griesoft.AspNetCore.ReCaptcha.TagHelpers; 2 | 3 | namespace Griesoft.AspNetCore.ReCaptcha.Configuration 4 | { 5 | /// 6 | /// Options for this reCAPTCHA service. You can set your global default values for the service on app startup. 7 | /// 8 | public class RecaptchaOptions 9 | { 10 | private ValidationFailedAction _validationFailedAction = ValidationFailedAction.BlockRequest; 11 | 12 | /// 13 | /// If set to true the remote IP will be send to Google when verifying the response token. The default is false. 14 | /// 15 | public bool UseRemoteIp { get; set; } 16 | 17 | /// 18 | /// Configure the service on a global level whether it should block / short circuit the request pipeline 19 | /// when the reCPATCHA response token is invalid or not. The default is . 20 | /// 21 | /// 22 | /// This affects only the action filter logic of this service. This can also be set individually 23 | /// for each controller or action by setting a value to , 24 | /// like this [ValidateRecaptcha(ValidationFailedAction = ValidationFailedAction.ContinueRequest)]. 25 | /// 26 | /// The value may never be set to , it will always be translated to 27 | /// . 28 | /// 29 | public ValidationFailedAction ValidationFailedAction 30 | { 31 | get => _validationFailedAction; 32 | set => _validationFailedAction = value == ValidationFailedAction.Unspecified ? ValidationFailedAction.BlockRequest : value; 33 | } 34 | 35 | /// 36 | /// The global default size value for a reCAPTCHA tag. 37 | /// 38 | public Size Size { get; set; } = Size.Normal; 39 | 40 | /// 41 | /// The global default theme value for a reCAPTCHA tag. 42 | /// 43 | public Theme Theme { get; set; } = Theme.Light; 44 | 45 | /// 46 | /// The global default badge value for an invisible reCAPTCHA tag. 47 | /// 48 | public BadgePosition Badge { get; set; } = BadgePosition.BottomRight; 49 | 50 | 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/ReCaptcha/Configuration/RecaptchaServiceConstants.cs: -------------------------------------------------------------------------------- 1 | namespace Griesoft.AspNetCore.ReCaptcha.Configuration 2 | { 3 | /// 4 | /// Constant values for this service. 5 | /// 6 | public class RecaptchaServiceConstants 7 | { 8 | /// 9 | /// The validation endpoint. 10 | /// 11 | public const string GoogleRecaptchaEndpoint = "https://www.google.com/recaptcha/api/siteverify"; 12 | 13 | /// 14 | /// The header key name under which the token is stored. 15 | /// 16 | public const string TokenKeyName = "G-Recaptcha-Response"; 17 | 18 | /// 19 | /// The header key name under which the token is stored in lower case. 20 | /// 21 | public const string TokenKeyNameLower = "g-recaptcha-response"; 22 | 23 | /// 24 | /// The section name in the appsettings.json from which the settings are read. 25 | /// 26 | public const string SettingsSectionKey = "RecaptchaSettings"; 27 | 28 | /// 29 | /// The named HttpClient name that we use in the IRecpatchaService. 30 | /// 31 | public const string RecaptchaServiceHttpClientName = "ReCaptchaValidationClient"; 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/ReCaptcha/Configuration/RecaptchaSettings.cs: -------------------------------------------------------------------------------- 1 | using System.Runtime.Serialization; 2 | 3 | namespace Griesoft.AspNetCore.ReCaptcha.Configuration 4 | { 5 | /// 6 | /// Mandatory settings for this reCAPTCHA service. The values for this object will be read from your appsettings.json file. 7 | /// 8 | /// 9 | /// For more information about configuration in ASP.NET Core check out Microsoft docs: https://docs.microsoft.com/en-us/aspnet/core/fundamentals/configuration/?view=aspnetcore-3.1 10 | /// 11 | /// 12 | public class RecaptchaSettings 13 | { 14 | /// 15 | /// The reCAPTCHA site key. 16 | /// 17 | /// 18 | /// Will be added to reCAPTCHA HTML elements as the data-sitekey attribute. 19 | /// 20 | public string SiteKey { get; set; } = string.Empty; 21 | 22 | /// 23 | /// The reCAPTCHA secret key. 24 | /// 25 | public string SecretKey { get; set; } = string.Empty; 26 | 27 | /// 28 | /// Indicates if proxy server should be used to forward http client requests 29 | /// 30 | public bool? UseProxy { get; set; } 31 | 32 | /// 33 | /// Proxy server address to be used to http client 34 | /// 35 | public string? ProxyAddress { get; set; } 36 | 37 | /// 38 | /// Indicates whether to bypass proxy for local addresses 39 | /// 40 | public bool BypassOnLocal { get; set; } 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/ReCaptcha/Enums/BadgePosition.cs: -------------------------------------------------------------------------------- 1 | namespace Griesoft.AspNetCore.ReCaptcha.TagHelpers 2 | { 3 | /// 4 | /// Recaptcha badge options for the . 5 | /// 6 | public enum BadgePosition 7 | { 8 | /// 9 | /// Position the badge in the bottom left of your page. 10 | /// 11 | BottomLeft, 12 | 13 | /// 14 | /// Position the badge in the bottom right of your page. 15 | /// 16 | BottomRight, 17 | 18 | /// 19 | /// Use this if you want to customize the position with CSS yourself. 20 | /// 21 | Inline 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/ReCaptcha/Enums/Render.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | 3 | namespace Griesoft.AspNetCore.ReCaptcha.TagHelpers 4 | { 5 | /// 6 | /// Recaptcha rendering options for the . 7 | /// 8 | [Flags] 9 | public enum Render 10 | { 11 | /// 12 | /// The default rendering option. This will render your V2 reCAPTCHA elements automatically after the script has been loaded. 13 | /// 14 | Onload, 15 | 16 | /// 17 | /// When rendering your reCAPTCHA elements explicitly a given onloadCallback will be called after the script has been loaded. 18 | /// 19 | Explicit, 20 | 21 | /// 22 | /// Loads the reCAPTCHA V3 script. 23 | /// 24 | V3 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/ReCaptcha/Enums/Size.cs: -------------------------------------------------------------------------------- 1 | namespace Griesoft.AspNetCore.ReCaptcha.TagHelpers 2 | { 3 | /// 4 | /// Recaptcha size options for the . 5 | /// 6 | public enum Size 7 | { 8 | /// 9 | /// The default value for an reCAPTCHA element. 10 | /// 11 | Normal, 12 | 13 | /// 14 | /// A smaller and compact style option for the reCAPTCHA element. 15 | /// 16 | Compact 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/ReCaptcha/Enums/Theme.cs: -------------------------------------------------------------------------------- 1 | namespace Griesoft.AspNetCore.ReCaptcha.TagHelpers 2 | { 3 | /// 4 | /// Recaptcha theme options for the . 5 | /// 6 | public enum Theme 7 | { 8 | /// 9 | /// 10 | /// 11 | Light, 12 | 13 | /// 14 | /// 15 | /// 16 | Dark 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/ReCaptcha/Enums/ValidationError.cs: -------------------------------------------------------------------------------- 1 | namespace Griesoft.AspNetCore.ReCaptcha 2 | { 3 | /// 4 | /// Recaptcha validation error reason message enum fields. 5 | /// 6 | public enum ValidationError 7 | { 8 | /// 9 | /// Something went wrong in a very bad way. You can consider yourself lucky when you hit this error. 10 | /// 11 | Undefined, 12 | 13 | /// 14 | /// No input secret was provided. Make sure you have configured the service correctly. 15 | /// 16 | MissingInputSecret, 17 | 18 | /// 19 | /// The secret parameter is invalid or malformed. Make sure you have not switched the secret key with the site key accidentally. 20 | /// 21 | InvalidInputSecret, 22 | 23 | /// 24 | /// The response token is missing. 25 | /// 26 | MissingInputResponse, 27 | 28 | /// 29 | /// The response parameter is invalid or malformed. 30 | /// 31 | InvalidInputResponse, 32 | 33 | /// 34 | /// The request is invalid or malformed. 35 | /// 36 | BadRequest, 37 | 38 | /// 39 | /// The response is no longer valid: either is too old or has been used previously. 40 | /// 41 | TimeoutOrDuplicate, 42 | 43 | /// 44 | /// The connection to the reCAPTCHA validation endpoint failed. 45 | /// 46 | HttpRequestFailed 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/ReCaptcha/Enums/ValidationFailedAction.cs: -------------------------------------------------------------------------------- 1 | namespace Griesoft.AspNetCore.ReCaptcha 2 | { 3 | /// 4 | /// Options which specify what to do with a HTTP request when a reCAPTCHA response token was invalid. 5 | /// 6 | public enum ValidationFailedAction 7 | { 8 | /// 9 | /// 10 | /// 11 | Unspecified, 12 | 13 | /// 14 | /// The validation filter will block and stop execution of requests which did fail reCAPTCHA response verification. 15 | /// 16 | BlockRequest, 17 | 18 | /// 19 | /// The validation filter will allow the request to continue even if the reCAPTCHA response verification failed. 20 | /// 21 | ContinueRequest 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/ReCaptcha/Extensions/LoggerExtensions.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using Griesoft.AspNetCore.ReCaptcha.Localization; 3 | using Microsoft.Extensions.Logging; 4 | 5 | namespace Griesoft.AspNetCore.ReCaptcha.Extensions 6 | { 7 | internal static class LoggerExtensions 8 | { 9 | private static readonly Action _validationRequestFailed = LoggerMessage.Define( 10 | LogLevel.Warning, 11 | new EventId(1, nameof(ValidationRequestFailed)), 12 | Resources.RequestFailedErrorMessage); 13 | 14 | private static readonly Action _validationRequestUnexpectedException = LoggerMessage.Define( 15 | LogLevel.Critical, 16 | new EventId(2, nameof(ValidationRequestUnexpectedException)), 17 | Resources.ValidationUnexpectedErrorMessage); 18 | 19 | private static readonly Action _recaptchaResponseTokenMissing = LoggerMessage.Define( 20 | LogLevel.Warning, 21 | new EventId(3, nameof(RecaptchaResponseTokenMissing)), 22 | Resources.RecaptchaResponseTokenMissing); 23 | 24 | private static readonly Action _invalidResponseToken = LoggerMessage.Define( 25 | LogLevel.Information, 26 | new EventId(4, nameof(InvalidResponseToken)), 27 | Resources.InvalidResponseTokenMessage); 28 | 29 | public static void ValidationRequestFailed(this ILogger logger) 30 | { 31 | _validationRequestFailed(logger, null); 32 | } 33 | 34 | public static void ValidationRequestUnexpectedException(this ILogger logger, Exception exception) 35 | { 36 | _validationRequestUnexpectedException(logger, exception); 37 | } 38 | 39 | public static void RecaptchaResponseTokenMissing(this ILogger logger) 40 | { 41 | _recaptchaResponseTokenMissing(logger, null); 42 | } 43 | 44 | public static void InvalidResponseToken(this ILogger logger) 45 | { 46 | _invalidResponseToken(logger, null); 47 | } 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/ReCaptcha/Extensions/RecaptchaServiceExtensions.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Net; 3 | using System.Net.Http; 4 | using Griesoft.AspNetCore.ReCaptcha.Client; 5 | using Griesoft.AspNetCore.ReCaptcha.Configuration; 6 | using Griesoft.AspNetCore.ReCaptcha.Filters; 7 | using Griesoft.AspNetCore.ReCaptcha.Services; 8 | using Microsoft.Extensions.Configuration; 9 | using Microsoft.Extensions.Options; 10 | 11 | namespace Microsoft.Extensions.DependencyInjection 12 | { 13 | /// 14 | /// extension methods for easy service registration in the StartUp.cs. 15 | /// 16 | public static class RecaptchaServiceExtensions 17 | { 18 | /// 19 | /// Register the to the web project and all it's dependencies. 20 | /// 21 | /// 22 | /// Specify global options for the service. 23 | /// 24 | public static IServiceCollection AddRecaptchaService(this IServiceCollection services, Action? options = null) 25 | { 26 | services.AddOptions() 27 | .Configure((settings, config) => 28 | config.GetSection(RecaptchaServiceConstants.SettingsSectionKey) 29 | .Bind(settings, (op) => op.BindNonPublicProperties = true)); 30 | 31 | services.Configure(options ??= opt => { }); 32 | 33 | services.AddScoped(); 34 | 35 | services.AddHttpClient(RecaptchaServiceConstants.RecaptchaServiceHttpClientName, client => 36 | { 37 | client.BaseAddress = new Uri(RecaptchaServiceConstants.GoogleRecaptchaEndpoint); 38 | }) 39 | .ConfigurePrimaryHttpMessageHandler(); 40 | 41 | 42 | services.AddScoped(); 43 | 44 | services.AddTransient(); 45 | 46 | return services; 47 | } 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/ReCaptcha/Extensions/TagHelperOutputExtensions.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Globalization; 4 | using System.IO; 5 | using System.Linq; 6 | using System.Text; 7 | using System.Text.Encodings.Web; 8 | using Microsoft.AspNetCore.Html; 9 | using Microsoft.AspNetCore.Razor.TagHelpers; 10 | 11 | namespace Griesoft.AspNetCore.ReCaptcha.Extensions 12 | { 13 | // Copied some extension methods from https://github.com/aspnet/Mvc/blob/release/2.2/src/Microsoft.AspNetCore.Mvc.TagHelpers/TagHelperOutputExtensions.cs, 14 | // because they were only available for .NET Core 2.2+. 15 | // Also borrowing code from https://github.com/aspnet/HttpAbstractions/blob/master/src/Microsoft.AspNetCore.WebUtilities/QueryHelpers.cs#L63 16 | // to reduce dependency count, because we only need this one functionality from the package. 17 | internal static class TagHelperOutputExtensions 18 | { 19 | private static readonly char[] SpaceChars = { '\u0020', '\u0009', '\u000A', '\u000C', '\u000D' }; 20 | 21 | internal static string AddQueryString(string uri, IEnumerable> queryString) 22 | { 23 | if (uri == null) 24 | { 25 | throw new ArgumentNullException(nameof(uri)); 26 | } 27 | 28 | if (queryString == null) 29 | { 30 | throw new ArgumentNullException(nameof(queryString)); 31 | } 32 | 33 | var anchorIndex = uri.IndexOf('#'); 34 | var uriToBeAppended = uri; 35 | var anchorText = ""; 36 | // If there is an anchor, then the query string must be inserted before its first occurence. 37 | if (anchorIndex != -1) 38 | { 39 | anchorText = uri.Substring(anchorIndex); 40 | uriToBeAppended = uri.Substring(0, anchorIndex); 41 | } 42 | 43 | var queryIndex = uriToBeAppended.IndexOf('?'); 44 | var hasQuery = queryIndex != -1; 45 | 46 | var sb = new StringBuilder(); 47 | sb.Append(uriToBeAppended); 48 | foreach (var parameter in queryString) 49 | { 50 | sb.Append(hasQuery ? '&' : '?'); 51 | sb.Append(UrlEncoder.Default.Encode(parameter.Key)); 52 | sb.Append('='); 53 | sb.Append(UrlEncoder.Default.Encode(parameter.Value)); 54 | hasQuery = true; 55 | } 56 | 57 | sb.Append(anchorText); 58 | return sb.ToString(); 59 | } 60 | 61 | #if NET462 62 | internal static void AddClass(this TagHelperOutput tagHelperOutput, string classValue, HtmlEncoder htmlEncoder) 63 | { 64 | if (tagHelperOutput == null) 65 | { 66 | throw new ArgumentNullException(nameof(tagHelperOutput)); 67 | } 68 | 69 | if (string.IsNullOrEmpty(classValue)) 70 | { 71 | return; 72 | } 73 | 74 | var encodedSpaceChars = SpaceChars.Where(x => !x.Equals('\u0020')).Select(x => htmlEncoder.Encode(x.ToString(CultureInfo.InvariantCulture))).ToArray(); 75 | 76 | if (SpaceChars.Any(classValue.Contains) || encodedSpaceChars.Any(value => classValue.Contains(value))) 77 | { 78 | throw new ArgumentException(null, nameof(classValue)); 79 | } 80 | 81 | if (!tagHelperOutput.Attributes.TryGetAttribute("class", out var classAttribute)) 82 | { 83 | tagHelperOutput.Attributes.Add("class", classValue); 84 | } 85 | else 86 | { 87 | var currentClassValue = ExtractClassValue(classAttribute, htmlEncoder); 88 | 89 | var encodedClassValue = htmlEncoder.Encode(classValue); 90 | 91 | if (string.Equals(currentClassValue, encodedClassValue, StringComparison.Ordinal)) 92 | { 93 | return; 94 | } 95 | 96 | var arrayOfClasses = currentClassValue.Split(SpaceChars, StringSplitOptions.RemoveEmptyEntries) 97 | .SelectMany(perhapsEncoded => perhapsEncoded.Split(encodedSpaceChars, StringSplitOptions.RemoveEmptyEntries)) 98 | .ToArray(); 99 | 100 | if (arrayOfClasses.Contains(encodedClassValue, StringComparer.Ordinal)) 101 | { 102 | return; 103 | } 104 | 105 | var newClassAttribute = new TagHelperAttribute( 106 | classAttribute.Name, 107 | new HtmlString($"{currentClassValue} {encodedClassValue}"), 108 | classAttribute.ValueStyle); 109 | 110 | tagHelperOutput.Attributes.SetAttribute(newClassAttribute); 111 | } 112 | } 113 | 114 | private static string ExtractClassValue(TagHelperAttribute classAttribute, HtmlEncoder htmlEncoder) 115 | { 116 | string? extractedClassValue; 117 | switch (classAttribute.Value) 118 | { 119 | case string valueAsString: 120 | extractedClassValue = htmlEncoder.Encode(valueAsString); 121 | break; 122 | case HtmlString valueAsHtmlString: 123 | extractedClassValue = valueAsHtmlString.Value; 124 | break; 125 | case IHtmlContent htmlContent: 126 | using (var stringWriter = new StringWriter()) 127 | { 128 | htmlContent.WriteTo(stringWriter, htmlEncoder); 129 | extractedClassValue = stringWriter.ToString(); 130 | } 131 | break; 132 | default: 133 | extractedClassValue = htmlEncoder.Encode(classAttribute.Value.ToString() ?? string.Empty); 134 | break; 135 | } 136 | var currentClassValue = extractedClassValue ?? string.Empty; 137 | return currentClassValue; 138 | } 139 | #endif 140 | } 141 | } 142 | -------------------------------------------------------------------------------- /src/ReCaptcha/Filters/IRecaptchaValidationFailedResult.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.Mvc; 2 | 3 | namespace Griesoft.AspNetCore.ReCaptcha.Filters 4 | { 5 | /// 6 | /// Represents an that is used when the reCAPTCHA validation failed. 7 | /// This can be matched inside MVC result filters to process the validation failure. 8 | /// 9 | public interface IRecaptchaValidationFailedResult : IActionResult 10 | { 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/ReCaptcha/Filters/IValidateRecaptchaFilter.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.Mvc.Filters; 2 | 3 | namespace Griesoft.AspNetCore.ReCaptcha.Filters 4 | { 5 | /// 6 | /// An action filter which does validate that the request contains a valid reCAPTCHA token. 7 | /// 8 | public interface IValidateRecaptchaFilter : IAsyncActionFilter 9 | { 10 | /// 11 | /// The action that the filter should take when validation of a token fails. 12 | /// 13 | public ValidationFailedAction OnValidationFailedAction { get; set; } 14 | 15 | /// 16 | /// The reCAPTCHA V3 action name. 17 | /// 18 | /// 19 | /// This will also be validated for a matching action name in the validation response from the reCAPTCHA service. 20 | /// 21 | public string? Action { get; set; } 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/ReCaptcha/Filters/RecaptchaValidationFailedResult.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Threading.Tasks; 3 | using Microsoft.AspNetCore.Mvc; 4 | 5 | namespace Griesoft.AspNetCore.ReCaptcha.Filters 6 | { 7 | /// 8 | /// A bad request result used for reCAPTCHA validation failures. Use to 9 | /// match for validation failures inside MVC result filters. 10 | /// 11 | public class RecaptchaValidationFailedResult : IActionResult, IRecaptchaValidationFailedResult 12 | { 13 | /// 14 | public Task ExecuteResultAsync(ActionContext context) 15 | { 16 | if (context == null) 17 | { 18 | throw new ArgumentNullException(nameof(context)); 19 | } 20 | 21 | context.HttpContext.Response.StatusCode = 400; 22 | 23 | return Task.CompletedTask; 24 | } 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/ReCaptcha/Filters/ValidateRecaptchaFilter.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | using System.Diagnostics.CodeAnalysis; 3 | using System.Linq; 4 | using System.Runtime.CompilerServices; 5 | using System.Threading.Tasks; 6 | using Griesoft.AspNetCore.ReCaptcha.Configuration; 7 | using Griesoft.AspNetCore.ReCaptcha.Extensions; 8 | using Griesoft.AspNetCore.ReCaptcha.Services; 9 | using Microsoft.AspNetCore.Http; 10 | using Microsoft.AspNetCore.Mvc.Filters; 11 | using Microsoft.Extensions.Logging; 12 | using Microsoft.Extensions.Options; 13 | 14 | [assembly: InternalsVisibleTo("ReCaptcha.Tests")] 15 | namespace Griesoft.AspNetCore.ReCaptcha.Filters 16 | { 17 | internal class ValidateRecaptchaFilter : IValidateRecaptchaFilter 18 | { 19 | private readonly IRecaptchaService _recaptchaService; 20 | private readonly RecaptchaOptions _options; 21 | private readonly ILogger _logger; 22 | 23 | public ValidateRecaptchaFilter(IRecaptchaService recaptchaService, IOptionsMonitor options, 24 | ILogger logger) 25 | { 26 | _recaptchaService = recaptchaService; 27 | _logger = logger; 28 | _options = options.CurrentValue; 29 | } 30 | 31 | public ValidationFailedAction OnValidationFailedAction { get; set; } = ValidationFailedAction.Unspecified; 32 | 33 | public string? Action { get; set; } 34 | 35 | public async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next) 36 | { 37 | if (OnValidationFailedAction == ValidationFailedAction.Unspecified) 38 | { 39 | OnValidationFailedAction = _options.ValidationFailedAction; 40 | } 41 | 42 | ValidationResponse validationResponse; 43 | 44 | if (!TryGetRecaptchaToken(context.HttpContext.Request, out string? token)) 45 | { 46 | _logger.RecaptchaResponseTokenMissing(); 47 | 48 | validationResponse = new ValidationResponse() 49 | { 50 | Success = false, 51 | ErrorMessages = new List() 52 | { 53 | "missing-input-response" 54 | } 55 | }; 56 | } 57 | else 58 | { 59 | validationResponse = await _recaptchaService.ValidateRecaptchaResponse(token, GetRemoteIp(context)).ConfigureAwait(true); 60 | } 61 | 62 | TryAddResponseToActionAguments(context, validationResponse); 63 | 64 | if (!ShouldShortCircuit(context, validationResponse)) 65 | { 66 | await next.Invoke().ConfigureAwait(true); 67 | } 68 | } 69 | 70 | private string? GetRemoteIp(ActionExecutingContext context) 71 | { 72 | return _options.UseRemoteIp ? 73 | context.HttpContext.Connection.RemoteIpAddress?.ToString() : 74 | null; 75 | } 76 | private bool ShouldShortCircuit(ActionExecutingContext context, ValidationResponse response) 77 | { 78 | if (!response.Success || Action != response.Action) 79 | { 80 | _logger.InvalidResponseToken(); 81 | 82 | if (OnValidationFailedAction == ValidationFailedAction.BlockRequest) 83 | { 84 | context.Result = new RecaptchaValidationFailedResult(); 85 | return true; 86 | } 87 | } 88 | 89 | return false; 90 | } 91 | private static bool TryGetRecaptchaToken(HttpRequest request, [NotNullWhen(true)] out string? token) 92 | { 93 | if (request.Headers.TryGetValue(RecaptchaServiceConstants.TokenKeyName, out var headerVal)) 94 | { 95 | token = headerVal; 96 | } 97 | else if (request.HasFormContentType && request.Form.TryGetValue(RecaptchaServiceConstants.TokenKeyNameLower, out var formVal)) 98 | { 99 | token = formVal; 100 | } 101 | else if (request.Query.TryGetValue(RecaptchaServiceConstants.TokenKeyNameLower, out var queryVal)) 102 | { 103 | token = queryVal; 104 | } 105 | else 106 | { 107 | token = null; 108 | } 109 | 110 | return token != null; 111 | } 112 | private static void TryAddResponseToActionAguments(ActionExecutingContext context, ValidationResponse response) 113 | { 114 | if (context.ActionArguments.Any(pair => pair.Value is ValidationResponse)) 115 | { 116 | context.ActionArguments[context.ActionArguments.First(pair => pair.Value is ValidationResponse).Key] = response; 117 | } 118 | } 119 | } 120 | } 121 | -------------------------------------------------------------------------------- /src/ReCaptcha/Localization/Resources.Designer.cs: -------------------------------------------------------------------------------- 1 | //------------------------------------------------------------------------------ 2 | // 3 | // This code was generated by a tool. 4 | // Runtime Version:4.0.30319.42000 5 | // 6 | // Changes to this file may cause incorrect behavior and will be lost if 7 | // the code is regenerated. 8 | // 9 | //------------------------------------------------------------------------------ 10 | 11 | namespace Griesoft.AspNetCore.ReCaptcha.Localization { 12 | using System; 13 | 14 | 15 | /// 16 | /// A strongly-typed resource class, for looking up localized strings, etc. 17 | /// 18 | // This class was auto-generated by the StronglyTypedResourceBuilder 19 | // class via a tool like ResGen or Visual Studio. 20 | // To add or remove a member, edit your .ResX file then rerun ResGen 21 | // with the /str option, or rebuild your VS project. 22 | [global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "17.0.0.0")] 23 | [global::System.Diagnostics.DebuggerNonUserCodeAttribute()] 24 | [global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()] 25 | internal class Resources { 26 | 27 | private static global::System.Resources.ResourceManager resourceMan; 28 | 29 | private static global::System.Globalization.CultureInfo resourceCulture; 30 | 31 | [global::System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode")] 32 | internal Resources() { 33 | } 34 | 35 | /// 36 | /// Returns the cached ResourceManager instance used by this class. 37 | /// 38 | [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)] 39 | internal static global::System.Resources.ResourceManager ResourceManager { 40 | get { 41 | if (object.ReferenceEquals(resourceMan, null)) { 42 | global::System.Resources.ResourceManager temp = new global::System.Resources.ResourceManager("Griesoft.AspNetCore.ReCaptcha.Localization.Resources", typeof(Resources).Assembly); 43 | resourceMan = temp; 44 | } 45 | return resourceMan; 46 | } 47 | } 48 | 49 | /// 50 | /// Overrides the current thread's CurrentUICulture property for all 51 | /// resource lookups using this strongly typed resource class. 52 | /// 53 | [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)] 54 | internal static global::System.Globalization.CultureInfo Culture { 55 | get { 56 | return resourceCulture; 57 | } 58 | set { 59 | resourceCulture = value; 60 | } 61 | } 62 | 63 | /// 64 | /// Looks up a localized string similar to The action is a madatory property, but was not set. . 65 | /// 66 | internal static string ActionPropertyNullErrorMessage { 67 | get { 68 | return ResourceManager.GetString("ActionPropertyNullErrorMessage", resourceCulture); 69 | } 70 | } 71 | 72 | /// 73 | /// Looks up a localized string similar to A callback function name must be specified. Invisible reCAPTCHA does not work without it.. 74 | /// 75 | internal static string CallbackPropertyNullErrorMessage { 76 | get { 77 | return ResourceManager.GetString("CallbackPropertyNullErrorMessage", resourceCulture); 78 | } 79 | } 80 | 81 | /// 82 | /// Looks up a localized string similar to The reCAPTCHA response token was not valid. Blocked a bot there, yay!. 83 | /// 84 | internal static string InvalidResponseTokenMessage { 85 | get { 86 | return ResourceManager.GetString("InvalidResponseTokenMessage", resourceCulture); 87 | } 88 | } 89 | 90 | /// 91 | /// Looks up a localized string similar to Could not retrieve the reCAPTCHA response token for validation.. 92 | /// 93 | internal static string RecaptchaResponseTokenMissing { 94 | get { 95 | return ResourceManager.GetString("RecaptchaResponseTokenMissing", resourceCulture); 96 | } 97 | } 98 | 99 | /// 100 | /// Looks up a localized string similar to The reCAPTCHA validation HTTP request failed.. 101 | /// 102 | internal static string RequestFailedErrorMessage { 103 | get { 104 | return ResourceManager.GetString("RequestFailedErrorMessage", resourceCulture); 105 | } 106 | } 107 | 108 | /// 109 | /// Looks up a localized string similar to The required services for the ValidateRecaptchaAttribute are not registered.. 110 | /// 111 | internal static string RequiredServiceNotRegisteredErrorMessage { 112 | get { 113 | return ResourceManager.GetString("RequiredServiceNotRegisteredErrorMessage", resourceCulture); 114 | } 115 | } 116 | 117 | /// 118 | /// Looks up a localized string similar to Something went wrong very badly at the reCAPTCHA validation process.. 119 | /// 120 | internal static string ValidationUnexpectedErrorMessage { 121 | get { 122 | return ResourceManager.GetString("ValidationUnexpectedErrorMessage", resourceCulture); 123 | } 124 | } 125 | } 126 | } 127 | -------------------------------------------------------------------------------- /src/ReCaptcha/Localization/Resources.resx: -------------------------------------------------------------------------------- 1 |  2 | 3 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | text/microsoft-resx 110 | 111 | 112 | 2.0 113 | 114 | 115 | System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 116 | 117 | 118 | System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 119 | 120 | 121 | The action is a madatory property, but was not set. 122 | 123 | 124 | A callback function name must be specified. Invisible reCAPTCHA does not work without it. 125 | 126 | 127 | The reCAPTCHA response token was not valid. Blocked a bot there, yay! 128 | 129 | 130 | Could not retrieve the reCAPTCHA response token for validation. 131 | 132 | 133 | The reCAPTCHA validation HTTP request failed. 134 | 135 | 136 | The required services for the ValidateRecaptchaAttribute are not registered. 137 | 138 | 139 | Something went wrong very badly at the reCAPTCHA validation process. 140 | 141 | -------------------------------------------------------------------------------- /src/ReCaptcha/Models/ValidationResponse.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Runtime.CompilerServices; 4 | using Newtonsoft.Json; 5 | 6 | [assembly: InternalsVisibleTo("ReCaptcha.Tests")] 7 | namespace Griesoft.AspNetCore.ReCaptcha 8 | { 9 | /// 10 | /// Recaptcha validation response model. 11 | /// 12 | public class ValidationResponse 13 | { 14 | /// 15 | /// Validation success status. 16 | /// 17 | [JsonProperty(PropertyName = "success")] 18 | public bool Success { get; set; } 19 | 20 | /// 21 | /// The score for this request (0.0 - 1.0). Only used with reCAPTCHA V3. 22 | /// 23 | [JsonProperty(PropertyName = "score")] 24 | public double? Score { get; set; } = null; 25 | 26 | /// 27 | /// The action name for this request (important to verify). Only used with reCAPTCHA V3. 28 | /// 29 | [JsonProperty(PropertyName = "action")] 30 | public string? Action { get; set; } = null; 31 | 32 | /// 33 | /// Time stamp of the challenge load. 34 | /// 35 | [JsonProperty(PropertyName = "challenge_ts")] 36 | public DateTime ChallengeTimeStamp { get; set; } 37 | 38 | /// 39 | /// The host name of the site where the reCAPTCHA was solved. 40 | /// 41 | [JsonProperty(PropertyName = "hostname")] 42 | public string Hostname { get; set; } = string.Empty; 43 | 44 | /// 45 | /// List of 's, if any occurred. 46 | /// 47 | [JsonIgnore] 48 | public IEnumerable Errors => GetValidationErrors(); 49 | 50 | [JsonProperty(PropertyName = "error-codes")] 51 | internal List ErrorMessages { get; set; } = new List(); 52 | 53 | private IEnumerable GetValidationErrors() 54 | { 55 | foreach (var s in ErrorMessages) 56 | { 57 | yield return s switch 58 | { 59 | "missing-input-secret" => ValidationError.MissingInputSecret, 60 | "invalid-input-secret" => ValidationError.InvalidInputSecret, 61 | "missing-input-response" => ValidationError.MissingInputResponse, 62 | "invalid-input-response" => ValidationError.InvalidInputResponse, 63 | "bad-request" => ValidationError.BadRequest, 64 | "timeout-or-duplicate" => ValidationError.TimeoutOrDuplicate, 65 | "request-failed" => ValidationError.HttpRequestFailed, 66 | _ => ValidationError.Undefined 67 | }; 68 | } 69 | } 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /src/ReCaptcha/ReCaptcha.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net462;net6.0;net8.0 5 | Griesoft.AspNetCore.ReCaptcha 6 | Griesoft.AspNetCore.ReCaptcha 7 | Griesoft 8 | Joonas Griesinger 9 | jgdevlabs,jooni91 10 | ASP.NET Core reCAPTCHA Service 11 | MIT 12 | enable 13 | latest 14 | A Google reCAPTCHA service for ASP.NET Core. Keep bots away from submitting forms or other actions in just a few steps. 15 | 2024 © Griesoft 16 | https://github.com/griesoft/aspnetcore-recaptcha 17 | https://github.com/griesoft/aspnetcore-recaptcha 18 | en 19 | aspnetcore;recaptcha;aspnetcoremvc;recaptcha-v2;recaptcha-v3 20 | true 21 | snupkg 22 | true 23 | ..\..\docs\Griesoft.AspNetCore.ReCaptcha.xml 24 | README.md 25 | True 26 | latest-recommended 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | all 36 | runtime; build; native; contentfiles; analyzers; buildtransitive 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | ResXFileCodeGenerator 53 | Resources.Designer.cs 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | True 64 | \ 65 | 66 | 67 | 68 | 69 | 70 | True 71 | True 72 | Resources.resx 73 | 74 | 75 | 76 | 77 | -------------------------------------------------------------------------------- /src/ReCaptcha/Services/IRecaptchaService.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Threading.Tasks; 3 | 4 | namespace Griesoft.AspNetCore.ReCaptcha.Services 5 | { 6 | /// 7 | /// A service for reCAPTCHA response back-end validation. 8 | /// 9 | public interface IRecaptchaService 10 | { 11 | /// 12 | /// Access the validation response of the last validation that this service did perform. 13 | /// 14 | /// 15 | /// This service is registered as transient (or should be) which means the validation response will 16 | /// always match the request that instantiated this service. 17 | /// 18 | ValidationResponse? ValidationResponse { get; } 19 | 20 | /// 21 | /// Validate the reCAPTCHA response token. 22 | /// 23 | /// The response token. 24 | /// If set, the remote IP will be send to Google for validation too. 25 | /// 26 | /// 27 | Task ValidateRecaptchaResponse(string token, string? remoteIp = null); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/ReCaptcha/Services/RecaptchaService.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Net.Http; 4 | using System.Runtime.CompilerServices; 5 | using System.Threading.Tasks; 6 | using Griesoft.AspNetCore.ReCaptcha.Configuration; 7 | using Griesoft.AspNetCore.ReCaptcha.Extensions; 8 | using Griesoft.AspNetCore.ReCaptcha.Localization; 9 | using Microsoft.Extensions.Logging; 10 | using Microsoft.Extensions.Options; 11 | using Newtonsoft.Json; 12 | 13 | [assembly: InternalsVisibleTo("ReCaptcha.Tests")] 14 | namespace Griesoft.AspNetCore.ReCaptcha.Services 15 | { 16 | /// 17 | internal class RecaptchaService : IRecaptchaService 18 | { 19 | private readonly RecaptchaSettings _settings; 20 | private readonly IHttpClientFactory _httpClientFactory; 21 | private readonly ILogger _logger; 22 | 23 | public RecaptchaService(IOptionsMonitor settings, 24 | IHttpClientFactory httpClientFactory, ILogger logger) 25 | { 26 | _settings = settings.CurrentValue; 27 | _httpClientFactory = httpClientFactory; 28 | _logger = logger; 29 | } 30 | 31 | /// 32 | public ValidationResponse? ValidationResponse { get; private set; } 33 | 34 | /// 35 | public async Task ValidateRecaptchaResponse(string token, string? remoteIp = null) 36 | { 37 | _ = token ?? throw new ArgumentNullException(nameof(token)); 38 | 39 | try 40 | { 41 | var httpClient = _httpClientFactory.CreateClient(RecaptchaServiceConstants.RecaptchaServiceHttpClientName); 42 | var response = await httpClient.PostAsync($"?secret={_settings.SecretKey}&response={token}{(remoteIp != null ? $"&remoteip={remoteIp}" : "")}", null!) 43 | .ConfigureAwait(true); 44 | 45 | response.EnsureSuccessStatusCode(); 46 | 47 | ValidationResponse = JsonConvert.DeserializeObject( 48 | await response.Content.ReadAsStringAsync() 49 | .ConfigureAwait(true)) 50 | ?? new ValidationResponse() 51 | { 52 | Success = false, 53 | ErrorMessages = new List() 54 | { 55 | "response-deserialization-failed" 56 | } 57 | }; 58 | } 59 | catch (HttpRequestException) 60 | { 61 | _logger.ValidationRequestFailed(); 62 | ValidationResponse = new ValidationResponse() 63 | { 64 | Success = false, 65 | ErrorMessages = new List() 66 | { 67 | "request-failed" 68 | } 69 | }; 70 | } 71 | catch (Exception ex) 72 | { 73 | _logger.ValidationRequestUnexpectedException(ex); 74 | throw; 75 | } 76 | 77 | return ValidationResponse; 78 | } 79 | } 80 | } 81 | 82 | -------------------------------------------------------------------------------- /src/ReCaptcha/TagHelpers/CallbackScriptTagHelperComponent.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Runtime.CompilerServices; 3 | using Microsoft.AspNetCore.Razor.TagHelpers; 4 | 5 | [assembly: InternalsVisibleTo("ReCaptcha.Tests")] 6 | namespace Griesoft.AspNetCore.ReCaptcha.TagHelpers 7 | { 8 | /// 9 | /// This tag helper component is used to add a short callback script to the bottom of a body tag. 10 | /// 11 | /// 12 | /// The callback script is used as a default callback function to submit a form after a reCAPTCHA challenge was successful. 13 | /// 14 | internal class CallbackScriptTagHelperComponent : TagHelperComponent 15 | { 16 | private readonly string _formId; 17 | 18 | public CallbackScriptTagHelperComponent(string formId) 19 | { 20 | if (string.IsNullOrEmpty(formId)) 21 | { 22 | throw new ArgumentNullException(nameof(formId)); 23 | } 24 | 25 | _formId = formId; 26 | } 27 | 28 | public override void Process(TagHelperContext context, TagHelperOutput output) 29 | { 30 | if (string.Equals(context.TagName, "body", StringComparison.OrdinalIgnoreCase)) 31 | { 32 | output.PostContent.AppendHtml(CallbackScript(_formId)); 33 | } 34 | } 35 | 36 | public static string CallbackScript(string formId) 37 | { 38 | // Append the formId to the function name in case that multiple recaptcha tags are added in a document. 39 | return $""; 40 | } 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/ReCaptcha/TagHelpers/RecaptchaInvisibleTagHelper.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Text.Encodings.Web; 3 | using Griesoft.AspNetCore.ReCaptcha.Configuration; 4 | using Griesoft.AspNetCore.ReCaptcha.Localization; 5 | using Microsoft.AspNetCore.Mvc.Razor.TagHelpers; 6 | using Microsoft.AspNetCore.Razor.TagHelpers; 7 | using Microsoft.Extensions.Options; 8 | 9 | #if !NET462 10 | using Microsoft.AspNetCore.Mvc.TagHelpers; 11 | #else 12 | using Griesoft.AspNetCore.ReCaptcha.Extensions; 13 | #endif 14 | 15 | namespace Griesoft.AspNetCore.ReCaptcha.TagHelpers 16 | { 17 | /// 18 | /// Add a invisible reCAPTCHA div element to your page. You may also use it by adding a 're-invisible' attribute to a button 19 | /// element which will automatically bind the challenge to it. 20 | /// 21 | /// 22 | /// The is required. With the exception that you set a instead. 23 | /// When setting both the value set to Callback always wins and the FormId value is basically irrelevant. 24 | /// 25 | /// For easiest use of this tag helper set only the FormId. This will add a default callback function to the body. That function does 26 | /// submit the form after a successful reCAPTCHA challenge. 27 | /// 28 | /// If the tag is not inside the form that is going to be submitted, you should use a custom callback function. The default callback function 29 | /// does not add the reCAPTCHA token to the form, which will result in response verification failure. 30 | /// 31 | /// 32 | /// The simplest use of the tag would be: 33 | /// 34 | /// 35 | /// 36 | /// 37 | /// Which will translate into the following HTML: 38 | /// 39 | /// 40 | /// 41 | /// 42 | [HtmlTargetElement("recaptcha-invisible", Attributes = "callback", TagStructure = TagStructure.WithoutEndTag)] 43 | [HtmlTargetElement("recaptcha-invisible", Attributes = "form-id", TagStructure = TagStructure.WithoutEndTag)] 44 | [HtmlTargetElement("button", Attributes = "re-invisible,callback")] 45 | [HtmlTargetElement("button", Attributes = "re-invisible,form-id")] 46 | public class RecaptchaInvisibleTagHelper : TagHelper 47 | { 48 | private readonly ITagHelperComponentManager _tagHelperComponentManager; 49 | private readonly RecaptchaSettings _settings; 50 | private readonly RecaptchaOptions _options; 51 | 52 | /// 53 | /// 54 | /// 55 | /// 56 | /// 57 | /// 58 | /// 59 | public RecaptchaInvisibleTagHelper(IOptionsMonitor settings, IOptionsMonitor options, 60 | ITagHelperComponentManager tagHelperComponentManager) 61 | { 62 | _ = settings ?? throw new ArgumentNullException(nameof(settings)); 63 | _ = options ?? throw new ArgumentNullException(nameof(options)); 64 | _ = tagHelperComponentManager ?? throw new ArgumentNullException(nameof(tagHelperComponentManager)); 65 | 66 | _settings = settings.CurrentValue; 67 | _options = options.CurrentValue; 68 | _tagHelperComponentManager = tagHelperComponentManager; 69 | 70 | Badge = _options.Badge; 71 | } 72 | 73 | /// 74 | /// Set the badge position for the reCAPTCHA element. 75 | /// Set this to when you want to position it with CSS yourself. 76 | /// 77 | public BadgePosition Badge { get; set; } 78 | 79 | /// 80 | /// Set the tabindex of the reCAPTCHA element. If other elements in your page use tabindex, it should be set to make user navigation easier. 81 | /// 82 | public int? TabIndex { get; set; } 83 | 84 | /// 85 | /// The id of the form that will be submitted after a successful reCAPTCHA challenge. 86 | /// 87 | /// This does only apply when not specifying a . 88 | public string? FormId { get; set; } 89 | 90 | /// 91 | /// Set the name of your callback function, which is called when the reCAPTCHA challenge was successful. 92 | /// A "g-recaptcha-response" token is added to your callback function parameters for server-side verification. 93 | /// 94 | public string? Callback { get; set; } 95 | 96 | /// 97 | /// Set the name of your callback function, executed when the reCAPTCHA response expires and the user needs to re-verify. 98 | /// 99 | public string? ExpiredCallback { get; set; } 100 | 101 | /// 102 | /// Set the name of your callback function, executed when reCAPTCHA encounters an error (usually network connectivity) and cannot continue until connectivity is restored. 103 | /// If you specify a function here, you are responsible for informing the user that they should retry. 104 | /// 105 | public string? ErrorCallback { get; set; } 106 | 107 | /// 108 | /// 109 | /// Thrown when both and are null or empty. 110 | public override void Process(TagHelperContext context, TagHelperOutput output) 111 | { 112 | _ = output ?? throw new ArgumentNullException(nameof(output)); 113 | 114 | if (string.IsNullOrEmpty(Callback) && string.IsNullOrEmpty(FormId)) 115 | { 116 | throw new InvalidOperationException(Resources.CallbackPropertyNullErrorMessage); 117 | } 118 | 119 | if (output.TagName == "button") 120 | { 121 | output.Attributes.Remove(output.Attributes["re-invisible"]); 122 | } 123 | else 124 | { 125 | output.TagName = "div"; 126 | 127 | output.Attributes.SetAttribute("data-size", "invisible"); 128 | } 129 | 130 | if (string.IsNullOrEmpty(Callback)) 131 | { 132 | Callback = $"submit{FormId}"; 133 | _tagHelperComponentManager.Components.Add(new CallbackScriptTagHelperComponent(FormId!)); 134 | } 135 | 136 | output.TagMode = TagMode.StartTagAndEndTag; 137 | 138 | output.AddClass("g-recaptcha", HtmlEncoder.Default); 139 | 140 | output.Attributes.SetAttribute("data-sitekey", _settings.SiteKey); 141 | output.Attributes.SetAttribute("data-badge", Badge.ToString().ToLowerInvariant()); 142 | output.Attributes.SetAttribute("data-callback", Callback); 143 | 144 | if (TabIndex != null) 145 | { 146 | output.Attributes.SetAttribute("data-tabindex", TabIndex); 147 | } 148 | 149 | if (!string.IsNullOrEmpty(ExpiredCallback)) 150 | { 151 | output.Attributes.SetAttribute("data-expired-callback", ExpiredCallback); 152 | } 153 | 154 | if (!string.IsNullOrEmpty(ErrorCallback)) 155 | { 156 | output.Attributes.SetAttribute("data-error-callback", ErrorCallback); 157 | } 158 | } 159 | } 160 | } 161 | -------------------------------------------------------------------------------- /src/ReCaptcha/TagHelpers/RecaptchaScriptTagHelper.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Runtime.CompilerServices; 4 | using Griesoft.AspNetCore.ReCaptcha.Configuration; 5 | using Griesoft.AspNetCore.ReCaptcha.Extensions; 6 | using Microsoft.AspNetCore.Razor.TagHelpers; 7 | using Microsoft.Extensions.Options; 8 | 9 | [assembly: InternalsVisibleTo("ReCaptcha.Tests")] 10 | namespace Griesoft.AspNetCore.ReCaptcha.TagHelpers 11 | { 12 | /// 13 | /// Adds a script tag, which will load the required reCAPTCHA API. 14 | /// 15 | /// 16 | /// In case that you use an onload callback function you must place this tag after the callback script, to avoid race conditions. 17 | /// 18 | [HtmlTargetElement("recaptcha-script", TagStructure = TagStructure.WithoutEndTag)] 19 | public class RecaptchaScriptTagHelper : TagHelper 20 | { 21 | internal const string RecaptchaScriptEndpoint = "https://www.google.com/recaptcha/api.js"; 22 | 23 | private readonly RecaptchaSettings _settings; 24 | 25 | /// 26 | /// 27 | /// 28 | /// 29 | /// 30 | public RecaptchaScriptTagHelper(IOptionsMonitor settings) 31 | { 32 | _ = settings ?? throw new ArgumentNullException(nameof(settings)); 33 | 34 | _settings = settings.CurrentValue; 35 | } 36 | 37 | /// 38 | /// Set the rendering mode for the script. 39 | /// 40 | public Render Render { get; set; } = Render.Onload; 41 | 42 | /// 43 | /// Set the name of your callback function, that will be executed when reCAPTCHA has finished loading. 44 | /// 45 | public string? OnloadCallback { get; set; } 46 | 47 | /// 48 | /// Set a language code to force reCAPTCHA loading in the specified language. If not set language 49 | /// will be detected automatically by reCAPTCHA. For a list of valid language codes visit: 50 | /// https://developers.google.com/recaptcha/docs/language 51 | /// 52 | public string? Language { get; set; } 53 | 54 | /// 55 | public override void Process(TagHelperContext context, TagHelperOutput output) 56 | { 57 | _ = output ?? throw new ArgumentNullException(nameof(output)); 58 | 59 | output.TagName = "script"; 60 | output.TagMode = TagMode.StartTagAndEndTag; 61 | 62 | var queryCollection = new Dictionary(); 63 | 64 | if (!string.IsNullOrEmpty(OnloadCallback) && !Render.HasFlag(Render.V3)) 65 | { 66 | queryCollection.Add("onload", OnloadCallback!); 67 | } 68 | 69 | if (Render == (Render.V3 | Render.Explicit)) 70 | { 71 | queryCollection.Add("render", _settings.SiteKey); 72 | } 73 | else if (Render == Render.Explicit) 74 | { 75 | queryCollection.Add("render", Render.ToString().ToLowerInvariant()); 76 | } 77 | 78 | if (!string.IsNullOrEmpty(Language) && !Render.HasFlag(Render.V3)) 79 | { 80 | queryCollection.Add("hl", Language!); 81 | } 82 | 83 | output.Attributes.SetAttribute("src", TagHelperOutputExtensions.AddQueryString(RecaptchaScriptEndpoint, queryCollection)); 84 | 85 | output.Attributes.SetAttribute("async", null); 86 | output.Attributes.SetAttribute("defer", null); 87 | 88 | output.Content.Clear(); 89 | } 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /src/ReCaptcha/TagHelpers/RecaptchaTagHelper.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Text.Encodings.Web; 3 | using Griesoft.AspNetCore.ReCaptcha.Configuration; 4 | using Microsoft.AspNetCore.Razor.TagHelpers; 5 | using Microsoft.Extensions.Options; 6 | 7 | #if !NET462 8 | using Microsoft.AspNetCore.Mvc.TagHelpers; 9 | #else 10 | using Griesoft.AspNetCore.ReCaptcha.Extensions; 11 | #endif 12 | 13 | namespace Griesoft.AspNetCore.ReCaptcha.TagHelpers 14 | { 15 | /// 16 | /// A tag helper which adds a Google reCAPTCHA check box to your page. 17 | /// 18 | /// 19 | /// If the reCAPTCHA element is outside of the form, the response token is not included in your form which will result in response verification failure. 20 | /// This can be prevented by either placing the reCAPTCHA inside your form or by using a callback function which will add the token to your form after the 21 | /// challenge was successfully completed. 22 | /// 23 | /// 24 | /// The simplest use of the tag would be: 25 | /// 26 | /// 27 | /// 28 | /// 29 | /// Which will translate into the following HTML: 30 | /// 31 | ///
32 | ///
33 | ///
34 | public class RecaptchaTagHelper : TagHelper 35 | { 36 | private readonly RecaptchaSettings _settings; 37 | private readonly RecaptchaOptions _options; 38 | 39 | /// 40 | /// 41 | /// 42 | /// 43 | /// 44 | public RecaptchaTagHelper(IOptionsMonitor settings, IOptionsMonitor options) 45 | { 46 | _ = settings ?? throw new ArgumentNullException(nameof(settings)); 47 | _ = options ?? throw new ArgumentNullException(nameof(options)); 48 | 49 | _settings = settings.CurrentValue; 50 | _options = options.CurrentValue; 51 | 52 | Theme = _options.Theme; 53 | Size = _options.Size; 54 | } 55 | 56 | /// 57 | /// Set the theme for the reCAPTCHA element. 58 | /// 59 | /// 60 | /// The invisible theme is not a option, because you should use instead for that. 61 | /// 62 | public Theme Theme { get; set; } 63 | 64 | /// 65 | /// Set the size for the reCAPTCHA element. 66 | /// 67 | public Size Size { get; set; } 68 | 69 | /// 70 | /// Set the tabindex of the reCAPTCHA element. If other elements in your page use tabindex, it should be set to make user navigation easier. 71 | /// 72 | public int? TabIndex { get; set; } 73 | 74 | /// 75 | /// 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. 76 | /// 77 | public string? Callback { get; set; } 78 | 79 | /// 80 | /// Set the name of your callback function, executed when the reCAPTCHA response expires and the user needs to re-verify. 81 | /// 82 | public string? ExpiredCallback { get; set; } 83 | 84 | /// 85 | /// Set the name of your callback function, executed when reCAPTCHA encounters an error (usually network connectivity) and cannot continue until connectivity is restored. 86 | /// If you specify a function here, you are responsible for informing the user that they should retry. 87 | /// 88 | public string? ErrorCallback { get; set; } 89 | 90 | /// 91 | /// 92 | public override void Process(TagHelperContext context, TagHelperOutput output) 93 | { 94 | _ = output ?? throw new ArgumentNullException(nameof(output)); 95 | 96 | output.TagName = "div"; 97 | output.TagMode = TagMode.StartTagAndEndTag; 98 | 99 | output.AddClass("g-recaptcha", HtmlEncoder.Default); 100 | 101 | output.Attributes.SetAttribute("data-sitekey", _settings.SiteKey); 102 | output.Attributes.SetAttribute("data-size", Size.ToString().ToLowerInvariant()); 103 | output.Attributes.SetAttribute("data-theme", Theme.ToString().ToLowerInvariant()); 104 | 105 | if (TabIndex != null) 106 | { 107 | output.Attributes.SetAttribute("data-tabindex", TabIndex); 108 | } 109 | 110 | if (!string.IsNullOrEmpty(Callback)) 111 | { 112 | output.Attributes.SetAttribute("data-callback", Callback); 113 | } 114 | 115 | if (!string.IsNullOrEmpty(ExpiredCallback)) 116 | { 117 | output.Attributes.SetAttribute("data-expired-callback", ExpiredCallback); 118 | } 119 | 120 | if (!string.IsNullOrEmpty(ErrorCallback)) 121 | { 122 | output.Attributes.SetAttribute("data-error-callback", ErrorCallback); 123 | } 124 | } 125 | } 126 | } 127 | -------------------------------------------------------------------------------- /src/ReCaptcha/TagHelpers/RecaptchaV3TagHelper.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Text.Encodings.Web; 3 | using Griesoft.AspNetCore.ReCaptcha.Configuration; 4 | using Griesoft.AspNetCore.ReCaptcha.Localization; 5 | using Microsoft.AspNetCore.Mvc.Razor.TagHelpers; 6 | using Microsoft.AspNetCore.Razor.TagHelpers; 7 | using Microsoft.Extensions.Options; 8 | 9 | #if !NET462 10 | using Microsoft.AspNetCore.Mvc.TagHelpers; 11 | #else 12 | using Griesoft.AspNetCore.ReCaptcha.Extensions; 13 | #endif 14 | 15 | namespace Griesoft.AspNetCore.ReCaptcha.TagHelpers 16 | { 17 | /// 18 | /// A reCAPTCHA V3 tag helper, which can be used to automatically bind the challenge to a button. 19 | /// 20 | /// 21 | /// The is required. With the exception that you set a instead. 22 | /// When setting both the value set to Callback always wins and the FormId value is basically irrelevant. 23 | /// 24 | /// For easiest use of this tag helper set only the FormId. This will add a default callback function to the body. That function does 25 | /// submit the form after a successful reCAPTCHA challenge. 26 | /// 27 | /// If the tag is not inside the form that is going to be submitted, you should use a custom callback function. The default callback function 28 | /// does not add the reCAPTCHA token to the form, which will result in response verification failure. 29 | /// 30 | /// 31 | /// The simplest use of the tag would be: 32 | /// 33 | /// Submit 34 | /// 35 | /// 36 | /// Which will translate into the following HTML: 37 | /// 38 | /// 39 | /// 40 | /// 41 | [HtmlTargetElement("recaptcha-v3", Attributes = "callback,action")] 42 | [HtmlTargetElement("recaptcha-v3", Attributes = "form-id,action")] 43 | public class RecaptchaV3TagHelper : TagHelper 44 | { 45 | private readonly ITagHelperComponentManager _tagHelperComponentManager; 46 | private readonly RecaptchaSettings _settings; 47 | 48 | /// 49 | /// 50 | /// 51 | /// 52 | /// 53 | /// 54 | public RecaptchaV3TagHelper(IOptionsMonitor settings, ITagHelperComponentManager tagHelperComponentManager) 55 | { 56 | _ = settings ?? throw new ArgumentNullException(nameof(settings)); 57 | _ = tagHelperComponentManager ?? throw new ArgumentNullException(nameof(tagHelperComponentManager)); 58 | 59 | _settings = settings.CurrentValue; 60 | _tagHelperComponentManager = tagHelperComponentManager; 61 | } 62 | 63 | /// 64 | /// The id of the form that will be submitted after a successful reCAPTCHA challenge. 65 | /// 66 | /// This does only apply when not specifying a . 67 | public string? FormId { get; set; } 68 | 69 | /// 70 | /// Set the name of your callback function, which is called when the reCAPTCHA challenge was successful. 71 | /// A "g-recaptcha-response" token is added to your callback function parameters for server-side verification. 72 | /// 73 | public string? Callback { get; set; } 74 | 75 | /// 76 | /// The name of the action that was triggered. 77 | /// 78 | /// You should verify that the server-side verification response returns the same action. 79 | public string Action { get; set; } = string.Empty; 80 | 81 | /// 82 | /// 83 | /// Thrown when both and or are/is null or empty. 84 | public override void Process(TagHelperContext context, TagHelperOutput output) 85 | { 86 | _ = output ?? throw new ArgumentNullException(nameof(output)); 87 | 88 | if (string.IsNullOrEmpty(Callback) && string.IsNullOrEmpty(FormId)) 89 | { 90 | throw new InvalidOperationException(Resources.CallbackPropertyNullErrorMessage); 91 | } 92 | 93 | if (string.IsNullOrEmpty(Action)) 94 | { 95 | throw new InvalidOperationException(Resources.ActionPropertyNullErrorMessage); 96 | } 97 | 98 | if (string.IsNullOrEmpty(Callback)) 99 | { 100 | Callback = $"submit{FormId}"; 101 | _tagHelperComponentManager.Components.Add(new CallbackScriptTagHelperComponent(FormId!)); 102 | } 103 | 104 | output.TagMode = TagMode.StartTagAndEndTag; 105 | 106 | output.TagName = "button"; 107 | output.AddClass("g-recaptcha", HtmlEncoder.Default); 108 | 109 | output.Attributes.SetAttribute("data-sitekey", _settings.SiteKey); 110 | output.Attributes.SetAttribute("data-callback", Callback); 111 | output.Attributes.SetAttribute("data-action", Action); 112 | } 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /src/ReCaptcha/ValidateRecaptchaAttribute.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using Griesoft.AspNetCore.ReCaptcha.Filters; 3 | using Griesoft.AspNetCore.ReCaptcha.Localization; 4 | using Microsoft.AspNetCore.Mvc.Filters; 5 | 6 | namespace Griesoft.AspNetCore.ReCaptcha 7 | { 8 | /// 9 | /// Validates an incoming request that it contains a valid ReCaptcha token. 10 | /// 11 | /// 12 | /// Can be applied to a specific action or to a controller which would validate all incoming requests to it. 13 | /// 14 | [AttributeUsage(AttributeTargets.Class | AttributeTargets.Method)] 15 | public sealed class ValidateRecaptchaAttribute : Attribute, IFilterFactory, IOrderedFilter 16 | { 17 | /// 18 | public int Order { get; } 19 | 20 | /// 21 | public bool IsReusable { get; } 22 | 23 | /// 24 | /// If set to , the requests that do not contain a valid reCAPTCHA response token will be canceled. 25 | /// If this is set to anything else than , this will override the global behavior. 26 | /// 27 | public ValidationFailedAction ValidationFailedAction { get; set; } = ValidationFailedAction.Unspecified; 28 | 29 | /// 30 | /// The name of the action that is verified. 31 | /// 32 | /// 33 | /// This is a reCAPTCHA V3 feature and should be used only when validating V3 challenges. 34 | /// 35 | public string? Action { get; set; } 36 | 37 | 38 | /// 39 | /// Creates an instance of the executable filter. 40 | /// 41 | /// The request . 42 | /// An instance of the executable filter. 43 | /// Thrown if the required services are not registered. 44 | public IFilterMetadata CreateInstance(IServiceProvider serviceProvider) 45 | { 46 | #pragma warning disable CA1062 // Validate arguments of public methods 47 | var filter = serviceProvider.GetService(typeof(IValidateRecaptchaFilter)) as IValidateRecaptchaFilter; 48 | #pragma warning restore CA1062 // Validate arguments of public methods 49 | 50 | _ = filter ?? throw new InvalidOperationException(Resources.RequiredServiceNotRegisteredErrorMessage); 51 | 52 | filter.OnValidationFailedAction = ValidationFailedAction; 53 | filter.Action = Action; 54 | 55 | return filter; 56 | } 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /tests/ReCaptcha.Tests/Client/ProxyHttpClientHandlerTests.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using Griesoft.AspNetCore.ReCaptcha; 3 | using Griesoft.AspNetCore.ReCaptcha.Client; 4 | using Griesoft.AspNetCore.ReCaptcha.Configuration; 5 | using Microsoft.Extensions.Options; 6 | using Moq; 7 | using NUnit.Framework; 8 | 9 | namespace ReCaptcha.Tests.Client 10 | { 11 | [TestFixture] 12 | public class ProxyHttpClientHandlerTests 13 | { 14 | [Test] 15 | public void Initialize_WithProxy() 16 | { 17 | // Arrange 18 | var settingsMock = new Mock>(); 19 | settingsMock.SetupGet(instance => instance.Value) 20 | .Returns(new RecaptchaSettings() 21 | { 22 | UseProxy = true, 23 | ProxyAddress = "http://10.1.2.3:80" 24 | }); 25 | 26 | // Act 27 | var handler = new ProxyHttpClientHandler(settingsMock.Object); 28 | 29 | // Assert 30 | Assert.IsTrue(handler.UseProxy); 31 | Assert.IsNotNull(handler.Proxy); 32 | Assert.IsFalse(handler.Proxy.IsBypassed(new Uri("http://127.0.0.1:8080"))); 33 | 34 | } 35 | 36 | [Test] 37 | public void Initialize_WithProxyBypassed() 38 | { 39 | // Arrange 40 | var settingsMock = new Mock>(); 41 | settingsMock.SetupGet(instance => instance.Value) 42 | .Returns(new RecaptchaSettings() 43 | { 44 | UseProxy = true, 45 | ProxyAddress = "http://10.1.2.3:80", 46 | BypassOnLocal = true 47 | }); 48 | 49 | // Act 50 | var handler = new ProxyHttpClientHandler(settingsMock.Object); 51 | 52 | // Assert 53 | Assert.IsTrue(handler.UseProxy); 54 | Assert.IsNotNull(handler.Proxy); 55 | Assert.IsTrue(handler.Proxy.IsBypassed(new Uri("http://127.0.0.1:8080"))); 56 | } 57 | 58 | [Test] 59 | public void Initialize_NoProxy() 60 | { 61 | // Arrange 62 | var settingsMock = new Mock>(); 63 | settingsMock.SetupGet(instance => instance.Value) 64 | .Returns(new RecaptchaSettings() 65 | { 66 | UseProxy = false 67 | }); 68 | 69 | // Act 70 | var handler = new ProxyHttpClientHandler(settingsMock.Object); 71 | 72 | // Assert 73 | Assert.IsFalse(handler.UseProxy); 74 | Assert.IsNull(handler.Proxy); 75 | } 76 | 77 | [Test] 78 | public void Initialize_NotSpecified() 79 | { 80 | // Arrange 81 | var settingsMock = new Mock>(); 82 | settingsMock.SetupGet(instance => instance.Value) 83 | .Returns(new RecaptchaSettings() 84 | { 85 | }); 86 | 87 | // Act 88 | var handler = new ProxyHttpClientHandler(settingsMock.Object); 89 | 90 | // Assert 91 | Assert.IsFalse(handler.UseProxy); 92 | Assert.IsNull(handler.Proxy); 93 | } 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /tests/ReCaptcha.Tests/Configuration/RecaptchaOptionsTests.cs: -------------------------------------------------------------------------------- 1 | using Griesoft.AspNetCore.ReCaptcha; 2 | using Griesoft.AspNetCore.ReCaptcha.Configuration; 3 | using NUnit.Framework; 4 | 5 | namespace ReCaptcha.Tests.Configuration 6 | { 7 | [TestFixture] 8 | public class RecaptchaOptionsTests 9 | { 10 | [Test] 11 | public void ValidationFailedAction_ShouldNeverReturn_ValidationFailedActionUnspecified() 12 | { 13 | // Arrange 14 | var optionsUnmodified = new RecaptchaOptions(); 15 | var optionsModified = new RecaptchaOptions(); 16 | 17 | // Act 18 | optionsModified.ValidationFailedAction = ValidationFailedAction.Unspecified; 19 | 20 | // Assert 21 | Assert.AreNotEqual(ValidationFailedAction.Unspecified, optionsUnmodified.ValidationFailedAction); 22 | Assert.AreNotEqual(ValidationFailedAction.Unspecified, optionsModified.ValidationFailedAction); 23 | } 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /tests/ReCaptcha.Tests/Extensions/RecaptchaServiceExtensionsTests.cs: -------------------------------------------------------------------------------- 1 | using System.Linq; 2 | using Microsoft.Extensions.DependencyInjection; 3 | using NUnit.Framework; 4 | 5 | namespace ReCaptcha.Tests.Extensions 6 | { 7 | [TestFixture] 8 | public class RecaptchaServiceExtensionsTests 9 | { 10 | [Test] 11 | public void AddRecaptchaService_ShouldAddAllRequired_WithDefaultOptions() 12 | { 13 | // Arrange 14 | var services = new ServiceCollection(); 15 | 16 | // Act 17 | services.AddRecaptchaService(); 18 | 19 | // Assert 20 | Assert.IsTrue(services.Any(service => service.ServiceType.FullName == "Griesoft.AspNetCore.ReCaptcha.Services.IRecaptchaService" && service.Lifetime == ServiceLifetime.Scoped)); 21 | Assert.IsTrue(services.Any(service => service.ServiceType.FullName == "Griesoft.AspNetCore.ReCaptcha.Filters.IValidateRecaptchaFilter" && service.Lifetime == ServiceLifetime.Transient)); 22 | Assert.IsTrue(services.Any(service => service.ServiceType.FullName == "Microsoft.Extensions.Options.IConfigureOptions`1[[Griesoft.AspNetCore.ReCaptcha.Configuration.RecaptchaOptions, Griesoft.AspNetCore.ReCaptcha, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null]]")); 23 | Assert.IsTrue(services.Any(service => service.ServiceType.FullName == "Microsoft.Extensions.Options.IConfigureOptions`1[[Griesoft.AspNetCore.ReCaptcha.Configuration.RecaptchaSettings, Griesoft.AspNetCore.ReCaptcha, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null]]")); 24 | } 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /tests/ReCaptcha.Tests/Filters/RecaptchaValidationFailedResultTests.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Threading.Tasks; 3 | using Griesoft.AspNetCore.ReCaptcha.Filters; 4 | using Microsoft.AspNetCore.Http; 5 | using Microsoft.AspNetCore.Mvc; 6 | using Microsoft.AspNetCore.Mvc.Abstractions; 7 | using Microsoft.AspNetCore.Mvc.ModelBinding; 8 | using Microsoft.AspNetCore.Routing; 9 | using Moq; 10 | using NUnit.Framework; 11 | 12 | namespace ReCaptcha.Tests.Filters 13 | { 14 | [TestFixture] 15 | public class RecaptchaValidationFailedResultTests 16 | { 17 | [Test] 18 | public void Construction_IsSuccessful() 19 | { 20 | // Arrange 21 | 22 | 23 | // Act 24 | var result = new RecaptchaValidationFailedResult(); 25 | 26 | // Assert 27 | Assert.IsNotNull(result); 28 | } 29 | 30 | [Test] 31 | public void ExecuteResultAsync_ShouldThrow_ArgmentNullException() 32 | { 33 | // Arrange 34 | var result = new RecaptchaValidationFailedResult(); 35 | 36 | // Act 37 | 38 | 39 | // Assert 40 | Assert.ThrowsAsync(() => result.ExecuteResultAsync(null)); 41 | } 42 | 43 | [Test] 44 | public async Task ExecuteResultAsync_ShouldSet_StatusCode_400() 45 | { 46 | // Arrange 47 | var context = new ActionContext(new DefaultHttpContext(), Mock.Of(), 48 | Mock.Of(), new ModelStateDictionary()); 49 | 50 | var result = new RecaptchaValidationFailedResult(); 51 | 52 | // Act 53 | await result.ExecuteResultAsync(context); 54 | 55 | // Assert 56 | Assert.AreEqual(400, context.HttpContext.Response.StatusCode); 57 | } 58 | 59 | [Test] 60 | public void ExecuteResultAsync_ShouldReturn_CompletedTask() 61 | { 62 | // Arrange 63 | var context = new ActionContext(new DefaultHttpContext(), Mock.Of(), 64 | Mock.Of(), new ModelStateDictionary()); 65 | 66 | var result = new RecaptchaValidationFailedResult(); 67 | 68 | // Act 69 | var response = result.ExecuteResultAsync(context); 70 | 71 | // Assert 72 | Assert.IsTrue(response.IsCompleted); 73 | } 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /tests/ReCaptcha.Tests/Filters/ValidateRecaptchaFilterTests.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | using System.Linq; 3 | using System.Net; 4 | using System.Threading.Tasks; 5 | using Griesoft.AspNetCore.ReCaptcha; 6 | using Griesoft.AspNetCore.ReCaptcha.Configuration; 7 | using Griesoft.AspNetCore.ReCaptcha.Filters; 8 | using Griesoft.AspNetCore.ReCaptcha.Services; 9 | using Microsoft.AspNetCore.Http; 10 | using Microsoft.AspNetCore.Mvc; 11 | using Microsoft.AspNetCore.Mvc.Abstractions; 12 | using Microsoft.AspNetCore.Mvc.Filters; 13 | using Microsoft.AspNetCore.Mvc.ModelBinding; 14 | using Microsoft.AspNetCore.Routing; 15 | using Microsoft.Extensions.Logging; 16 | using Microsoft.Extensions.Options; 17 | using Microsoft.Extensions.Primitives; 18 | using Moq; 19 | using NUnit.Framework; 20 | 21 | namespace ReCaptcha.Tests.Filters 22 | { 23 | [TestFixture] 24 | public class ValidateRecaptchaFilterTests 25 | { 26 | private const string TokenValue = "test"; 27 | 28 | private ILogger _logger; 29 | private Mock> _optionsMock; 30 | private Mock _recaptchaServiceMock; 31 | private ActionContext _actionContext; 32 | private ActionExecutingContext _actionExecutingContext; 33 | private ActionExecutionDelegate _actionExecutionDelegate; 34 | private ValidateRecaptchaFilter _filter; 35 | 36 | [SetUp] 37 | public void InitializeTest() 38 | { 39 | _logger = new LoggerFactory().CreateLogger(); 40 | 41 | _optionsMock = new Mock>(); 42 | _optionsMock.SetupGet(options => options.CurrentValue) 43 | .Returns(new RecaptchaOptions()); 44 | 45 | _recaptchaServiceMock = new Mock(); 46 | _recaptchaServiceMock.Setup(service => service.ValidateRecaptchaResponse(It.Is(s => s == TokenValue), null)) 47 | .ReturnsAsync(new ValidationResponse 48 | { 49 | Success = true 50 | }) 51 | .Verifiable(); 52 | 53 | _actionContext = new ActionContext( 54 | new DefaultHttpContext(), 55 | Mock.Of(), 56 | Mock.Of(), 57 | new ModelStateDictionary()); 58 | 59 | _actionExecutingContext = new ActionExecutingContext(_actionContext, new List(), new Dictionary(), Mock.Of()) 60 | { 61 | Result = new OkResult() // It will return ok unless during code execution you change this when by condition 62 | }; 63 | 64 | #pragma warning disable CS1998 // Async method lacks 'await' operators and will run synchronously 65 | _actionExecutionDelegate = async () => { return new ActionExecutedContext(_actionContext, new List(), Mock.Of()); }; 66 | #pragma warning restore CS1998 // Async method lacks 'await' operators and will run synchronously 67 | 68 | _filter = new ValidateRecaptchaFilter(_recaptchaServiceMock.Object, _optionsMock.Object, _logger); 69 | } 70 | 71 | [Test] 72 | public void Construction_IsSuccessful() 73 | { 74 | // Arrange 75 | 76 | 77 | // Act 78 | var instance = new ValidateRecaptchaFilter(_recaptchaServiceMock.Object, _optionsMock.Object, _logger); 79 | 80 | // Assert 81 | Assert.NotNull(instance); 82 | } 83 | 84 | [Test(Description = "The default value for OnValidationFailedAction when creating a new instance of the filter " + 85 | "is Undefined. So we run the method and verify that the value was changed to something else.")] 86 | public async Task OnActionExecutionAsync_OnValidationFailedAction_IsNeverUndefined() 87 | { 88 | // Arrange 89 | 90 | 91 | // Act 92 | await _filter.OnActionExecutionAsync(_actionExecutingContext, _actionExecutionDelegate); 93 | 94 | // Assert 95 | Assert.AreEqual(ValidationFailedAction.BlockRequest, _filter.OnValidationFailedAction); 96 | } 97 | 98 | [Test] 99 | public async Task OnActionExecutionAsync_TokenIsExtracted_FromHeader() 100 | { 101 | // Arrange 102 | var httpContext = new DefaultHttpContext(); 103 | httpContext.Request.Headers.Add(RecaptchaServiceConstants.TokenKeyName, TokenValue); 104 | 105 | _actionExecutingContext.HttpContext = httpContext; 106 | 107 | // Act 108 | await _filter.OnActionExecutionAsync(_actionExecutingContext, _actionExecutionDelegate); 109 | 110 | // Assert 111 | _recaptchaServiceMock.Verify(); 112 | Assert.IsInstanceOf(_actionExecutingContext.Result); 113 | } 114 | 115 | [Test] 116 | public async Task OnActionExecutionAsync_TokenIsExtracted_FromQueryParameter() 117 | { 118 | // Arrange 119 | var httpContext = new DefaultHttpContext(); 120 | httpContext.Request.Query = new QueryCollection( 121 | new Dictionary() 122 | { 123 | { RecaptchaServiceConstants.TokenKeyName.ToLowerInvariant(), new StringValues(TokenValue) } 124 | } 125 | ); 126 | 127 | _actionExecutingContext.HttpContext = httpContext; 128 | 129 | // Act 130 | await _filter.OnActionExecutionAsync(_actionExecutingContext, _actionExecutionDelegate); 131 | 132 | // Assert 133 | _recaptchaServiceMock.Verify(); 134 | Assert.IsInstanceOf(_actionExecutingContext.Result); 135 | } 136 | 137 | [Test] 138 | public async Task OnActionExecutionAsync_TokenIsExtracted_FromFormData() 139 | { 140 | // Arrange 141 | var httpContext = new DefaultHttpContext(); 142 | httpContext.Request.Form = new FormCollection( 143 | new Dictionary() 144 | { 145 | { RecaptchaServiceConstants.TokenKeyName.ToLowerInvariant(), new StringValues(TokenValue) } 146 | } 147 | ); 148 | 149 | _actionExecutingContext.HttpContext = httpContext; 150 | 151 | // Act 152 | await _filter.OnActionExecutionAsync(_actionExecutingContext, _actionExecutionDelegate); 153 | 154 | // Assert 155 | _recaptchaServiceMock.Verify(); 156 | Assert.IsInstanceOf(_actionExecutingContext.Result); 157 | } 158 | 159 | [Test] 160 | public async Task OnActionExecutionAsync_WhenTokenNotFound_BlocksAndReturns_RecaptchaValidationFailedResult() 161 | { 162 | // Arrange 163 | 164 | 165 | // Act 166 | await _filter.OnActionExecutionAsync(_actionExecutingContext, _actionExecutionDelegate); 167 | 168 | // Assert 169 | _recaptchaServiceMock.VerifyNoOtherCalls(); 170 | Assert.IsInstanceOf(_actionExecutingContext.Result); 171 | } 172 | 173 | [Test] 174 | public async Task OnActionExecutionAsync_WhenTokenNotFound_ContinuesAndAddsResponseToArguments() 175 | { 176 | // Arrange 177 | _filter.OnValidationFailedAction = ValidationFailedAction.ContinueRequest; 178 | _actionExecutingContext.ActionArguments.Add("argumentName", new ValidationResponse { Success = true }); 179 | 180 | // Act 181 | await _filter.OnActionExecutionAsync(_actionExecutingContext, _actionExecutionDelegate); 182 | 183 | // Assert 184 | _recaptchaServiceMock.VerifyNoOtherCalls(); 185 | Assert.IsInstanceOf(_actionExecutingContext.Result); 186 | Assert.IsFalse((_actionExecutingContext.ActionArguments["argumentName"] as ValidationResponse).Success); 187 | Assert.GreaterOrEqual((_actionExecutingContext.ActionArguments["argumentName"] as ValidationResponse).Errors.Count(), 1); 188 | Assert.AreEqual(ValidationError.MissingInputResponse, (_actionExecutingContext.ActionArguments["argumentName"] as ValidationResponse).Errors.First()); 189 | } 190 | 191 | [Test] 192 | public async Task OnActionExecutionAsync_WhenValidationFailed_BlocksAndReturns_RecaptchaValidationFailedResult() 193 | { 194 | // Arrange 195 | var httpContext = new DefaultHttpContext(); 196 | httpContext.Request.Headers.Add(RecaptchaServiceConstants.TokenKeyName, TokenValue); 197 | 198 | _actionExecutingContext.HttpContext = httpContext; 199 | 200 | _recaptchaServiceMock = new Mock(); 201 | _recaptchaServiceMock.Setup(service => service.ValidateRecaptchaResponse(It.Is(s => s == TokenValue), null)) 202 | .ReturnsAsync(new ValidationResponse 203 | { 204 | Success = false 205 | }) 206 | .Verifiable(); 207 | 208 | _filter = new ValidateRecaptchaFilter(_recaptchaServiceMock.Object, _optionsMock.Object, _logger); 209 | 210 | // Act 211 | await _filter.OnActionExecutionAsync(_actionExecutingContext, _actionExecutionDelegate); 212 | 213 | // Assert 214 | _recaptchaServiceMock.Verify(); 215 | Assert.IsInstanceOf(_actionExecutingContext.Result); 216 | } 217 | 218 | [Test] 219 | public async Task OnActionExecutionAsync_WhenValidationFailed_ContinuesAndAddsResponseToArguments() 220 | { 221 | // Arrange 222 | var httpContext = new DefaultHttpContext(); 223 | httpContext.Request.Headers.Add(RecaptchaServiceConstants.TokenKeyName, TokenValue); 224 | 225 | _actionExecutingContext.HttpContext = httpContext; 226 | 227 | _recaptchaServiceMock = new Mock(); 228 | _recaptchaServiceMock.Setup(service => service.ValidateRecaptchaResponse(It.Is(s => s == TokenValue), null)) 229 | .ReturnsAsync(new ValidationResponse 230 | { 231 | Success = false, 232 | ErrorMessages = new List { "invalid-input-response" } 233 | }) 234 | .Verifiable(); 235 | 236 | _filter = new ValidateRecaptchaFilter(_recaptchaServiceMock.Object, _optionsMock.Object, _logger) 237 | { 238 | OnValidationFailedAction = ValidationFailedAction.ContinueRequest 239 | }; 240 | 241 | _actionExecutingContext.ActionArguments.Add("argumentName", new ValidationResponse { Success = true }); 242 | 243 | // Act 244 | await _filter.OnActionExecutionAsync(_actionExecutingContext, _actionExecutionDelegate); 245 | 246 | // Assert 247 | _recaptchaServiceMock.Verify(); 248 | Assert.IsInstanceOf(_actionExecutingContext.Result); 249 | Assert.IsFalse((_actionExecutingContext.ActionArguments["argumentName"] as ValidationResponse).Success); 250 | Assert.GreaterOrEqual((_actionExecutingContext.ActionArguments["argumentName"] as ValidationResponse).Errors.Count(), 1); 251 | Assert.AreEqual(ValidationError.InvalidInputResponse, (_actionExecutingContext.ActionArguments["argumentName"] as ValidationResponse).Errors.First()); 252 | } 253 | 254 | [Test] 255 | public async Task OnActionExecutionAsync_WhenActionDoesNotMatch_BlocksAndReturns_RecaptchaValidationFailedResult() 256 | { 257 | // Arrange 258 | var action = "submit"; 259 | var httpContext = new DefaultHttpContext(); 260 | httpContext.Request.Headers.Add(RecaptchaServiceConstants.TokenKeyName, TokenValue); 261 | 262 | _actionExecutingContext.HttpContext = httpContext; 263 | 264 | _recaptchaServiceMock = new Mock(); 265 | _recaptchaServiceMock.Setup(service => service.ValidateRecaptchaResponse(It.Is(s => s == TokenValue), null)) 266 | .ReturnsAsync(new ValidationResponse 267 | { 268 | Success = true 269 | }) 270 | .Verifiable(); 271 | 272 | _filter = new ValidateRecaptchaFilter(_recaptchaServiceMock.Object, _optionsMock.Object, _logger); 273 | _filter.Action = action; 274 | 275 | // Act 276 | await _filter.OnActionExecutionAsync(_actionExecutingContext, _actionExecutionDelegate); 277 | 278 | // Assert 279 | _recaptchaServiceMock.Verify(); 280 | Assert.IsInstanceOf(_actionExecutingContext.Result); 281 | } 282 | 283 | [Test] 284 | public async Task OnActionExecutionAsync_WhenActionDoesNotMatch_ContinuesAndAddsResponseToArguments() 285 | { 286 | // Arrange 287 | var action = "submit"; 288 | var httpContext = new DefaultHttpContext(); 289 | httpContext.Request.Headers.Add(RecaptchaServiceConstants.TokenKeyName, TokenValue); 290 | 291 | _actionExecutingContext.HttpContext = httpContext; 292 | 293 | _recaptchaServiceMock = new Mock(); 294 | _recaptchaServiceMock.Setup(service => service.ValidateRecaptchaResponse(It.Is(s => s == TokenValue), null)) 295 | .ReturnsAsync(new ValidationResponse 296 | { 297 | Success = true, 298 | ErrorMessages = new List { "invalid-input-response" } 299 | }) 300 | .Verifiable(); 301 | 302 | _filter = new ValidateRecaptchaFilter(_recaptchaServiceMock.Object, _optionsMock.Object, _logger) 303 | { 304 | OnValidationFailedAction = ValidationFailedAction.ContinueRequest, 305 | Action = action 306 | }; 307 | 308 | _actionExecutingContext.ActionArguments.Add("argumentName", new ValidationResponse { Success = true }); 309 | 310 | // Act 311 | await _filter.OnActionExecutionAsync(_actionExecutingContext, _actionExecutionDelegate); 312 | 313 | // Assert 314 | _recaptchaServiceMock.Verify(); 315 | Assert.IsInstanceOf(_actionExecutingContext.Result); 316 | Assert.IsTrue((_actionExecutingContext.ActionArguments["argumentName"] as ValidationResponse).Success); 317 | Assert.GreaterOrEqual((_actionExecutingContext.ActionArguments["argumentName"] as ValidationResponse).Errors.Count(), 1); 318 | Assert.AreEqual(ValidationError.InvalidInputResponse, (_actionExecutingContext.ActionArguments["argumentName"] as ValidationResponse).Errors.First()); 319 | } 320 | 321 | [Test] 322 | public async Task OnActionExecutionAsync_WhenValidationSuccess_ContinuesAndAddsResponseToArguments() 323 | { 324 | // Arrange 325 | var action = "submit"; 326 | var httpContext = new DefaultHttpContext(); 327 | httpContext.Request.Headers.Add(RecaptchaServiceConstants.TokenKeyName, TokenValue); 328 | 329 | _actionExecutingContext.HttpContext = httpContext; 330 | 331 | _recaptchaServiceMock = new Mock(); 332 | _recaptchaServiceMock.Setup(service => service.ValidateRecaptchaResponse(It.Is(s => s == TokenValue), null)) 333 | .ReturnsAsync(new ValidationResponse 334 | { 335 | Success = true, 336 | Action = action 337 | }) 338 | .Verifiable(); 339 | 340 | _filter = new ValidateRecaptchaFilter(_recaptchaServiceMock.Object, _optionsMock.Object, _logger) 341 | { 342 | Action = action 343 | }; 344 | 345 | _actionExecutingContext.ActionArguments.Add("argumentName", new ValidationResponse { Success = false, Action = string.Empty }); 346 | 347 | // Act 348 | await _filter.OnActionExecutionAsync(_actionExecutingContext, _actionExecutionDelegate); 349 | 350 | // Assert 351 | _recaptchaServiceMock.Verify(); 352 | Assert.IsInstanceOf(_actionExecutingContext.Result); 353 | Assert.IsTrue((_actionExecutingContext.ActionArguments["argumentName"] as ValidationResponse).Success); 354 | Assert.AreEqual(action, (_actionExecutingContext.ActionArguments["argumentName"] as ValidationResponse).Action); 355 | Assert.AreEqual((_actionExecutingContext.ActionArguments["argumentName"] as ValidationResponse).Errors.Count(), 0); 356 | } 357 | 358 | [Test] 359 | public async Task OnActionExecutionAsync_WhenValidationSuccess_ReturnsOkResult_WithoutAddingResponseToArguments() 360 | { 361 | // Arrange 362 | var httpContext = new DefaultHttpContext(); 363 | httpContext.Request.Headers.Add(RecaptchaServiceConstants.TokenKeyName, TokenValue); 364 | 365 | _actionExecutingContext.HttpContext = httpContext; 366 | 367 | _recaptchaServiceMock = new Mock(); 368 | _recaptchaServiceMock.Setup(service => service.ValidateRecaptchaResponse(It.Is(s => s == TokenValue), null)) 369 | .ReturnsAsync(new ValidationResponse 370 | { 371 | Success = true 372 | }) 373 | .Verifiable(); 374 | 375 | _filter = new ValidateRecaptchaFilter(_recaptchaServiceMock.Object, _optionsMock.Object, _logger); 376 | 377 | // Act 378 | await _filter.OnActionExecutionAsync(_actionExecutingContext, _actionExecutionDelegate); 379 | 380 | // Assert 381 | _recaptchaServiceMock.Verify(); 382 | Assert.IsInstanceOf(_actionExecutingContext.Result); 383 | Assert.AreEqual(0, _actionExecutingContext.ActionArguments.Count); 384 | } 385 | 386 | [Test] 387 | public async Task OnActionExecutionAsync_ShouldAddRemoteIp() 388 | { 389 | // Arrange 390 | var ip = "144.22.5.213"; 391 | var httpContext = new DefaultHttpContext(); 392 | httpContext.Request.Headers.Add(RecaptchaServiceConstants.TokenKeyName, TokenValue); 393 | httpContext.Connection.RemoteIpAddress = IPAddress.Parse(ip); 394 | 395 | _actionExecutingContext.HttpContext = httpContext; 396 | 397 | _optionsMock.SetupGet(options => options.CurrentValue) 398 | .Returns(new RecaptchaOptions { UseRemoteIp = true }); 399 | 400 | _recaptchaServiceMock = new Mock(); 401 | _recaptchaServiceMock.Setup(service => service.ValidateRecaptchaResponse(It.Is(s => s == TokenValue), It.Is(s => s == ip))) 402 | .ReturnsAsync(new ValidationResponse 403 | { 404 | Success = true 405 | }) 406 | .Verifiable(); 407 | 408 | _filter = new ValidateRecaptchaFilter(_recaptchaServiceMock.Object, _optionsMock.Object, _logger); 409 | 410 | // Act 411 | await _filter.OnActionExecutionAsync(_actionExecutingContext, _actionExecutionDelegate); 412 | 413 | // Assert 414 | _recaptchaServiceMock.Verify(); 415 | Assert.IsInstanceOf(_actionExecutingContext.Result); 416 | Assert.AreEqual(0, _actionExecutingContext.ActionArguments.Count); 417 | } 418 | } 419 | } 420 | -------------------------------------------------------------------------------- /tests/ReCaptcha.Tests/Models/RecaptchaValidationResponseTests.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | using System.Linq; 3 | using Griesoft.AspNetCore.ReCaptcha; 4 | using NUnit.Framework; 5 | 6 | namespace ReCaptcha.Tests.Models 7 | { 8 | [TestFixture] 9 | public class RecaptchaValidationResponseTests 10 | { 11 | [TestCase("missing-input-secret", ValidationError.MissingInputSecret)] 12 | [TestCase("invalid-input-secret", ValidationError.InvalidInputSecret)] 13 | [TestCase("missing-input-response", ValidationError.MissingInputResponse)] 14 | [TestCase("invalid-input-response", ValidationError.InvalidInputResponse)] 15 | [TestCase("bad-request", ValidationError.BadRequest)] 16 | [TestCase("timeout-or-duplicate", ValidationError.TimeoutOrDuplicate)] 17 | [TestCase("request-failed", ValidationError.HttpRequestFailed)] 18 | [TestCase("anything", ValidationError.Undefined)] 19 | public void Errors_ShouldReturnCorrectEnum_ForErrorMessage(string errorMessage, ValidationError expected) 20 | { 21 | // Arrange 22 | var response = new ValidationResponse() 23 | { 24 | ErrorMessages = new List() { errorMessage } 25 | }; 26 | 27 | // Act 28 | 29 | 30 | // Assert 31 | Assert.AreEqual(1, response.Errors.Count()); 32 | Assert.AreEqual(expected, response.Errors.First()); 33 | } 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /tests/ReCaptcha.Tests/ReCaptcha.Tests.csproj: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | net8.0 5 | 6 | false 7 | 8 | enable 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | all 18 | runtime; build; native; contentfiles; analyzers; buildtransitive 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | Assert 30 | 31 | 32 | 33 | 34 | -------------------------------------------------------------------------------- /tests/ReCaptcha.Tests/Services/RecaptchaServiceTests.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Linq; 3 | using System.Net; 4 | using System.Net.Http; 5 | using System.Threading; 6 | using System.Threading.Tasks; 7 | using Griesoft.AspNetCore.ReCaptcha; 8 | using Griesoft.AspNetCore.ReCaptcha.Configuration; 9 | using Griesoft.AspNetCore.ReCaptcha.Services; 10 | using Microsoft.Extensions.Logging; 11 | using Microsoft.Extensions.Options; 12 | using Moq; 13 | using Moq.Protected; 14 | using NUnit.Framework; 15 | 16 | namespace ReCaptcha.Tests.Services 17 | { 18 | [TestFixture] 19 | public class RecaptchaServiceTests 20 | { 21 | private const string SiteKey = "sitekey"; 22 | private const string SecretKey = "verysecretkey"; 23 | private const string Token = "testtoken"; 24 | 25 | private ILogger _logger; 26 | private Mock> _settingsMock; 27 | private Mock _httpMessageHandlerMock; 28 | private HttpClient _httpClient; 29 | private Mock _httpClientFactory; 30 | 31 | [SetUp] 32 | public void Initialize() 33 | { 34 | _logger = new LoggerFactory().CreateLogger(); 35 | 36 | _settingsMock = new Mock>(); 37 | _settingsMock.SetupGet(instance => instance.CurrentValue) 38 | .Returns(new RecaptchaSettings() 39 | { 40 | SiteKey = SiteKey, 41 | SecretKey = SecretKey 42 | }) 43 | .Verifiable(); 44 | 45 | _httpMessageHandlerMock = new Mock(); 46 | _httpMessageHandlerMock.Protected() 47 | .Setup>( 48 | "SendAsync", 49 | ItExpr.IsAny(), 50 | ItExpr.IsAny() 51 | ) 52 | .ReturnsAsync(new HttpResponseMessage() 53 | { 54 | StatusCode = HttpStatusCode.OK, 55 | Content = new StringContent("{'success': true, 'challenge_ts': '" + DateTime.UtcNow.ToString("o") + "', 'hostname': 'https://test.com', 'error-codes': []}") 56 | }) 57 | .Verifiable(); 58 | 59 | _httpClient = new HttpClient(_httpMessageHandlerMock.Object) 60 | { 61 | BaseAddress = new Uri("http://test.com/"), 62 | }; 63 | 64 | _httpClientFactory = new Mock(); 65 | _httpClientFactory.Setup(instance => instance.CreateClient(It.Is(val => val == RecaptchaServiceConstants.RecaptchaServiceHttpClientName))) 66 | .Returns(_httpClient) 67 | .Verifiable(); 68 | } 69 | 70 | [Test] 71 | public void Construction_IsSuccessful() 72 | { 73 | // Arrange 74 | 75 | 76 | // Act 77 | var instance = new RecaptchaService(_settingsMock.Object, _httpClientFactory.Object, _logger); 78 | 79 | // Assert 80 | Assert.NotNull(instance); 81 | _settingsMock.Verify(settings => settings.CurrentValue, Times.Once); 82 | } 83 | 84 | [Test] 85 | public void ValidateRecaptchaResponse_ShouldThrow_ArgumentNullException() 86 | { 87 | // Arrange 88 | var service = new RecaptchaService(_settingsMock.Object, _httpClientFactory.Object, _logger); 89 | 90 | // Act 91 | 92 | 93 | // Assert 94 | Assert.ThrowsAsync(() => service.ValidateRecaptchaResponse(null)); 95 | } 96 | 97 | [Test] 98 | public async Task ValidateRecaptchaResponse_Should_CreateNamedHttpClient() 99 | { 100 | // Arrange 101 | var service = new RecaptchaService(_settingsMock.Object, _httpClientFactory.Object, _logger); 102 | 103 | // Act 104 | var response = await service.ValidateRecaptchaResponse(Token); 105 | 106 | // Assert 107 | _httpClientFactory.Verify(); 108 | } 109 | 110 | [Test] 111 | public async Task ValidateRecaptchaResponse_ShouldReturn_HttpRequestError() 112 | { 113 | // Arrange 114 | _httpMessageHandlerMock = new Mock(); 115 | _httpMessageHandlerMock.Protected() 116 | .Setup>( 117 | "SendAsync", 118 | ItExpr.IsAny(), 119 | ItExpr.IsAny() 120 | ) 121 | .ReturnsAsync(new HttpResponseMessage() 122 | { 123 | StatusCode = HttpStatusCode.BadRequest 124 | }) 125 | .Verifiable(); 126 | 127 | _httpClient = new HttpClient(_httpMessageHandlerMock.Object) 128 | { 129 | BaseAddress = new Uri("http://test.com/"), 130 | }; 131 | 132 | _httpClientFactory = new Mock(); 133 | _httpClientFactory.Setup(instance => instance.CreateClient(It.Is(val => val == RecaptchaServiceConstants.RecaptchaServiceHttpClientName))) 134 | .Returns(_httpClient); 135 | 136 | var service = new RecaptchaService(_settingsMock.Object, _httpClientFactory.Object, _logger); 137 | 138 | // Act 139 | var response = await service.ValidateRecaptchaResponse(Token); 140 | 141 | // Assert 142 | _httpMessageHandlerMock.Verify(); 143 | Assert.GreaterOrEqual(response.Errors.Count(), 1); 144 | Assert.AreEqual(ValidationError.HttpRequestFailed, response.Errors.First()); 145 | } 146 | 147 | [Test] 148 | public void ValidateRecaptchaResponse_ShouldThrowAnyOtherThan_HttpRequestException() 149 | { 150 | // Arrange 151 | _httpMessageHandlerMock = new Mock(); 152 | _httpMessageHandlerMock.Protected() 153 | .Setup>( 154 | "SendAsync", 155 | ItExpr.IsAny(), 156 | ItExpr.IsAny() 157 | ) 158 | .ThrowsAsync(new Exception()) 159 | .Verifiable(); 160 | 161 | _httpClient = new HttpClient(_httpMessageHandlerMock.Object) 162 | { 163 | BaseAddress = new Uri("http://test.com/"), 164 | }; 165 | 166 | _httpClientFactory = new Mock(); 167 | _httpClientFactory.Setup(instance => instance.CreateClient(It.Is(val => val == RecaptchaServiceConstants.RecaptchaServiceHttpClientName))) 168 | .Returns(_httpClient); 169 | 170 | var service = new RecaptchaService(_settingsMock.Object, _httpClientFactory.Object, _logger); 171 | 172 | // Act 173 | 174 | 175 | // Assert 176 | Assert.ThrowsAsync(() => service.ValidateRecaptchaResponse(Token)); 177 | _httpMessageHandlerMock.Verify(); 178 | } 179 | 180 | [Test] 181 | public async Task ValidateRecaptchaResponse_ShouldReturn_DeserializedResponse() 182 | { 183 | // Arrange 184 | var service = new RecaptchaService(_settingsMock.Object, _httpClientFactory.Object, _logger); 185 | 186 | // Act 187 | var response = await service.ValidateRecaptchaResponse(Token); 188 | 189 | // Assert 190 | _httpMessageHandlerMock.Verify(); 191 | Assert.IsTrue(response.Success); 192 | Assert.AreEqual(0, response.Errors.Count()); 193 | } 194 | 195 | [Test] 196 | public async Task ValidateRecaptchaResponse_Should_DeserializedResponse_AndSet_ValidationResponseProperty() 197 | { 198 | // Arrange 199 | var service = new RecaptchaService(_settingsMock.Object, _httpClientFactory.Object, _logger); 200 | 201 | // Act 202 | var response = await service.ValidateRecaptchaResponse(Token); 203 | 204 | // Assert 205 | _httpMessageHandlerMock.Verify(); 206 | Assert.IsTrue(response.Success); 207 | Assert.AreEqual(0, response.Errors.Count()); 208 | Assert.NotNull(service.ValidationResponse); 209 | } 210 | } 211 | } 212 | -------------------------------------------------------------------------------- /tests/ReCaptcha.Tests/TagHelpers/CallbackScriptTagHelperComponentTests.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Threading.Tasks; 4 | using Griesoft.AspNetCore.ReCaptcha.TagHelpers; 5 | using Microsoft.AspNetCore.Razor.TagHelpers; 6 | using NUnit.Framework; 7 | 8 | namespace ReCaptcha.Tests.TagHelpers 9 | { 10 | [TestFixture] 11 | public class CallbackScriptTagHelperComponentTests 12 | { 13 | private TagHelperOutput _tagHelperOutput; 14 | 15 | [SetUp] 16 | public void Initialize() 17 | { 18 | _tagHelperOutput = new TagHelperOutput("body", 19 | new TagHelperAttributeList(), (useCachedResult, htmlEncoder) => 20 | { 21 | var tagHelperContent = new DefaultTagHelperContent(); 22 | tagHelperContent.SetContent(string.Empty); 23 | return Task.FromResult(tagHelperContent); 24 | }); 25 | } 26 | 27 | [Test] 28 | public void Construction_IsSuccessful() 29 | { 30 | // Arrange 31 | var formId = "formId"; 32 | 33 | // Act 34 | var instance = new CallbackScriptTagHelperComponent(formId); 35 | 36 | // Assert 37 | Assert.NotNull(instance); 38 | } 39 | 40 | [Test] 41 | public void Constructor_ShouldThrow_WhenFormIdNull() 42 | { 43 | // Arrange 44 | 45 | 46 | // Act 47 | 48 | 49 | // Assert 50 | Assert.Throws(() => new CallbackScriptTagHelperComponent(null)); 51 | } 52 | 53 | [Test] 54 | public void CallbackScript_ShouldReturn_ExpectedValue() 55 | { 56 | // Arrange 57 | var formId = "formId"; 58 | var expectedResult = $""; 59 | 60 | // Act 61 | var result = CallbackScriptTagHelperComponent.CallbackScript(formId); 62 | 63 | // Assert 64 | Assert.AreEqual(expectedResult, result); 65 | } 66 | 67 | [Test] 68 | public void Process_ShouldAppendScript_WhenTagIsBody() 69 | { 70 | // Arrange 71 | var formId = "formId"; 72 | var comp = new CallbackScriptTagHelperComponent(formId); 73 | var context = new TagHelperContext("body", new TagHelperAttributeList(), 74 | new Dictionary(), 75 | Guid.NewGuid().ToString("N")); 76 | 77 | // Act 78 | comp.Process(context, _tagHelperOutput); 79 | 80 | // Assert 81 | Assert.IsTrue(_tagHelperOutput.PostContent.GetContent().Contains(CallbackScriptTagHelperComponent.CallbackScript(formId))); 82 | } 83 | 84 | [Test] 85 | public void Process_ShouldSkip_WhenTagIsNotBody() 86 | { 87 | // Arrange 88 | var formId = "formId"; 89 | var comp = new CallbackScriptTagHelperComponent(formId); 90 | var context = new TagHelperContext("head", new TagHelperAttributeList(), 91 | new Dictionary(), 92 | Guid.NewGuid().ToString("N")); 93 | 94 | // Act 95 | comp.Process(context, _tagHelperOutput); 96 | 97 | // Assert 98 | Assert.IsTrue(_tagHelperOutput.PostContent.IsEmptyOrWhiteSpace); 99 | } 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /tests/ReCaptcha.Tests/TagHelpers/RecaptchaScriptTagHelperTests.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | using System.Threading.Tasks; 5 | using Griesoft.AspNetCore.ReCaptcha.Configuration; 6 | using Griesoft.AspNetCore.ReCaptcha.TagHelpers; 7 | using Microsoft.AspNetCore.Razor.TagHelpers; 8 | using Microsoft.AspNetCore.WebUtilities; 9 | using Microsoft.Extensions.Options; 10 | using Moq; 11 | using NUnit.Framework; 12 | 13 | namespace ReCaptcha.Tests.TagHelpers 14 | { 15 | [TestFixture] 16 | public class RecaptchaScriptTagHelperTests 17 | { 18 | private const string SiteKey = "unit_test_site_key"; 19 | 20 | private Mock> _settingsMock; 21 | private TagHelperOutput _tagHelperOutput; 22 | private TagHelperContext _context; 23 | 24 | [SetUp] 25 | public void Initialize() 26 | { 27 | _settingsMock = new Mock>(); 28 | _settingsMock.SetupGet(instance => instance.CurrentValue) 29 | .Returns(new RecaptchaSettings() 30 | { 31 | SiteKey = SiteKey, 32 | SecretKey = string.Empty 33 | }) 34 | .Verifiable(); 35 | 36 | _tagHelperOutput = new TagHelperOutput("recaptcha-script", 37 | new TagHelperAttributeList(), (useCachedResult, htmlEncoder) => 38 | { 39 | var tagHelperContent = new DefaultTagHelperContent(); 40 | tagHelperContent.SetContent(string.Empty); 41 | return Task.FromResult(tagHelperContent); 42 | }); 43 | 44 | _context = new TagHelperContext(new TagHelperAttributeList(), 45 | new Dictionary(), 46 | Guid.NewGuid().ToString("N")); 47 | } 48 | 49 | [Test] 50 | public void Construction_IsSuccessful() 51 | { 52 | // Arrange 53 | 54 | 55 | // Act 56 | var instance = new RecaptchaScriptTagHelper(_settingsMock.Object); 57 | 58 | // Assert 59 | Assert.NotNull(instance); 60 | _settingsMock.Verify(settings => settings.CurrentValue, Times.Once); 61 | } 62 | 63 | [Test] 64 | public void Constructor_ShouldThrow_WhenSettingsNull() 65 | { 66 | // Arrange 67 | 68 | 69 | // Act 70 | 71 | 72 | // Assert 73 | Assert.Throws(() => new RecaptchaScriptTagHelper(null)); 74 | } 75 | 76 | [Test] 77 | public void Process_ShouldThrow_WhenOutputTagIsNull() 78 | { 79 | // Arrange 80 | var scriptTagHelper = new RecaptchaScriptTagHelper(_settingsMock.Object); 81 | 82 | // Act 83 | 84 | 85 | // Assert 86 | Assert.Throws(() => scriptTagHelper.Process(_context, null)); 87 | } 88 | 89 | [Test] 90 | public void Process_ShouldChangeTagTo_ScriptTag() 91 | { 92 | // Arrange 93 | var scriptTagHelper = new RecaptchaScriptTagHelper(_settingsMock.Object); 94 | 95 | // Act 96 | scriptTagHelper.Process(_context, _tagHelperOutput); 97 | 98 | // Assert 99 | Assert.AreEqual("script", _tagHelperOutput.TagName); 100 | } 101 | 102 | [Test] 103 | public void Process_ShouldChange_TagModeTo_StartTagAndEndTag() 104 | { 105 | // Arrange 106 | var scriptTagHelper = new RecaptchaScriptTagHelper(_settingsMock.Object); 107 | _tagHelperOutput.TagMode = TagMode.SelfClosing; 108 | 109 | // Act 110 | scriptTagHelper.Process(_context, _tagHelperOutput); 111 | 112 | // Assert 113 | Assert.AreEqual(TagMode.StartTagAndEndTag, _tagHelperOutput.TagMode); 114 | } 115 | 116 | [Test] 117 | public void Process_ShouldAdd_CallbackToQuery() 118 | { 119 | // Arrange 120 | var callback = "myCallback"; 121 | var scriptTagHelper = new RecaptchaScriptTagHelper(_settingsMock.Object) 122 | { 123 | OnloadCallback = callback 124 | }; 125 | 126 | // Act 127 | scriptTagHelper.Process(_context, _tagHelperOutput); 128 | var query = QueryHelpers.ParseQuery(new Uri(_tagHelperOutput.Attributes["src"].Value.ToString()).Query); 129 | 130 | // Assert 131 | Assert.IsTrue(query.ContainsKey("onload")); 132 | Assert.AreEqual(callback, query["onload"]); 133 | } 134 | 135 | [Test] 136 | public void Process_ShouldNotAdd_CallbackToQuery_WhenRenderIsV3() 137 | { 138 | // Arrange 139 | var callback = "myCallback"; 140 | var scriptTagHelper = new RecaptchaScriptTagHelper(_settingsMock.Object) 141 | { 142 | OnloadCallback = callback, 143 | Render = Render.V3 144 | }; 145 | 146 | // Act 147 | scriptTagHelper.Process(_context, _tagHelperOutput); 148 | var query = QueryHelpers.ParseQuery(new Uri(_tagHelperOutput.Attributes["src"].Value.ToString()).Query); 149 | 150 | // Assert 151 | Assert.IsFalse(query.ContainsKey("onload")); 152 | } 153 | 154 | [Test] 155 | public void Process_ShouldAdd_RenderSiteKeyToQuery_WhenRenderIsV3AndExplicit() 156 | { 157 | // Arrange 158 | var scriptTagHelper = new RecaptchaScriptTagHelper(_settingsMock.Object) 159 | { 160 | Render = Render.V3 | Render.Explicit 161 | }; 162 | 163 | // Act 164 | scriptTagHelper.Process(_context, _tagHelperOutput); 165 | var query = QueryHelpers.ParseQuery(new Uri(_tagHelperOutput.Attributes["src"].Value.ToString()).Query); 166 | 167 | // Assert 168 | _settingsMock.Verify(); 169 | Assert.IsTrue(query.ContainsKey("render")); 170 | Assert.AreEqual(SiteKey, query["render"]); 171 | } 172 | 173 | [Test] 174 | public void Process_ShouldAdd_RenderExplicitToQuery_WhenRenderIsExplicit() 175 | { 176 | // Arrange 177 | var scriptTagHelper = new RecaptchaScriptTagHelper(_settingsMock.Object) 178 | { 179 | Render = Render.Explicit 180 | }; 181 | 182 | // Act 183 | scriptTagHelper.Process(_context, _tagHelperOutput); 184 | var query = QueryHelpers.ParseQuery(new Uri(_tagHelperOutput.Attributes["src"].Value.ToString()).Query); 185 | 186 | // Assert 187 | Assert.IsTrue(query.ContainsKey("render")); 188 | Assert.AreEqual("explicit", query["render"]); 189 | } 190 | 191 | [Test] 192 | public void Process_ShouldAddExplicit_ToQueryRenderKey() 193 | { 194 | // Arrange 195 | var scriptTagHelper = new RecaptchaScriptTagHelper(_settingsMock.Object) 196 | { 197 | Render = Render.Explicit 198 | }; 199 | 200 | // Act 201 | scriptTagHelper.Process(_context, _tagHelperOutput); 202 | var query = QueryHelpers.ParseQuery(new Uri(_tagHelperOutput.Attributes["src"].Value.ToString()).Query); 203 | 204 | // Assert 205 | Assert.AreEqual("explicit", query["render"]); 206 | } 207 | 208 | [Test] 209 | public void Process_ShouldAdd_HlToQuery() 210 | { 211 | // Arrange 212 | var language = "fi"; 213 | 214 | var scriptTagHelper = new RecaptchaScriptTagHelper(_settingsMock.Object) 215 | { 216 | Language = language 217 | }; 218 | 219 | // Act 220 | scriptTagHelper.Process(_context, _tagHelperOutput); 221 | var query = QueryHelpers.ParseQuery(new Uri(_tagHelperOutput.Attributes["src"].Value.ToString()).Query); 222 | 223 | // Assert 224 | Assert.IsTrue(query.ContainsKey("hl")); 225 | Assert.AreEqual(language, query["hl"]); 226 | } 227 | 228 | [Test] 229 | public void Process_ShouldNotAdd_HlToQuery_WhenRenderIsV3() 230 | { 231 | // Arrange 232 | var scriptTagHelper = new RecaptchaScriptTagHelper(_settingsMock.Object) 233 | { 234 | Render = Render.V3, 235 | Language = "fi" 236 | }; 237 | 238 | // Act 239 | scriptTagHelper.Process(_context, _tagHelperOutput); 240 | var query = QueryHelpers.ParseQuery(new Uri(_tagHelperOutput.Attributes["src"].Value.ToString()).Query); 241 | 242 | // Assert 243 | Assert.IsFalse(query.ContainsKey("hl")); 244 | } 245 | 246 | [Test] 247 | public void Process_ShouldAdd_SrcAttribute() 248 | { 249 | // Arrange 250 | var scriptTagHelper = new RecaptchaScriptTagHelper(_settingsMock.Object); 251 | 252 | // Act 253 | scriptTagHelper.Process(_context, _tagHelperOutput); 254 | 255 | // Assert 256 | Assert.IsTrue(_tagHelperOutput.Attributes.ContainsName("src")); 257 | Assert.AreEqual(RecaptchaScriptTagHelper.RecaptchaScriptEndpoint, _tagHelperOutput.Attributes["src"].Value); 258 | } 259 | 260 | [Test] 261 | public void Process_ShouldAdd_AsyncAttribute() 262 | { 263 | // Arrange 264 | var scriptTagHelper = new RecaptchaScriptTagHelper(_settingsMock.Object); 265 | 266 | // Act 267 | scriptTagHelper.Process(_context, _tagHelperOutput); 268 | 269 | // Assert 270 | Assert.IsTrue(_tagHelperOutput.Attributes.ContainsName("async")); 271 | } 272 | 273 | [Test] 274 | public void Process_ShouldAdd_DeferAttribute() 275 | { 276 | // Arrange 277 | var scriptTagHelper = new RecaptchaScriptTagHelper(_settingsMock.Object); 278 | 279 | // Act 280 | scriptTagHelper.Process(_context, _tagHelperOutput); 281 | 282 | // Assert 283 | Assert.IsTrue(_tagHelperOutput.Attributes.ContainsName("defer")); 284 | } 285 | 286 | [Test] 287 | public void Process_ShouldNotContain_Content() 288 | { 289 | // Arrange 290 | var scriptTagHelper = new RecaptchaScriptTagHelper(_settingsMock.Object); 291 | _tagHelperOutput.Content.SetContent("

Inner HTML

"); 292 | 293 | // Act 294 | scriptTagHelper.Process(_context, _tagHelperOutput); 295 | 296 | // Assert 297 | Assert.IsTrue(_tagHelperOutput.Content.IsEmptyOrWhiteSpace); 298 | } 299 | 300 | [Test] 301 | public void Process_ByDefault_ContainsThreeAttributes() 302 | { 303 | // Arrange 304 | var scriptTagHelper = new RecaptchaScriptTagHelper(_settingsMock.Object); 305 | 306 | // Act 307 | scriptTagHelper.Process(_context, _tagHelperOutput); 308 | 309 | // Assert 310 | Assert.AreEqual(3, _tagHelperOutput.Attributes.Count); 311 | Assert.IsTrue(_tagHelperOutput.Attributes.ContainsName("src")); 312 | Assert.IsTrue(_tagHelperOutput.Attributes.ContainsName("async")); 313 | Assert.IsTrue(_tagHelperOutput.Attributes.ContainsName("defer")); 314 | } 315 | } 316 | } 317 | -------------------------------------------------------------------------------- /tests/ReCaptcha.Tests/TagHelpers/RecaptchaTagHelperTests.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | using System.Threading.Tasks; 5 | using Griesoft.AspNetCore.ReCaptcha.Configuration; 6 | using Griesoft.AspNetCore.ReCaptcha.TagHelpers; 7 | using Microsoft.AspNetCore.Razor.TagHelpers; 8 | using Microsoft.Extensions.Options; 9 | using Moq; 10 | using NUnit.Framework; 11 | 12 | namespace ReCaptcha.Tests.TagHelpers 13 | { 14 | [TestFixture] 15 | public class RecaptchaTagHelperTests 16 | { 17 | private const string SiteKey = "unit_test_site_key"; 18 | 19 | private Mock> _settingsMock; 20 | private Mock> _optionsMock; 21 | private TagHelperOutput _tagHelperOutputStub; 22 | private TagHelperContext _contextStub; 23 | 24 | [SetUp] 25 | public void Initialize() 26 | { 27 | _settingsMock = new Mock>(); 28 | _settingsMock.SetupGet(instance => instance.CurrentValue) 29 | .Returns(new RecaptchaSettings() 30 | { 31 | SiteKey = SiteKey, 32 | SecretKey = string.Empty 33 | }) 34 | .Verifiable(); 35 | 36 | _optionsMock = new Mock>(); 37 | _optionsMock.SetupGet(instance => instance.CurrentValue) 38 | .Returns(new RecaptchaOptions()) 39 | .Verifiable(); 40 | 41 | _tagHelperOutputStub = new TagHelperOutput("recaptcha", 42 | new TagHelperAttributeList(), (useCachedResult, htmlEncoder) => 43 | { 44 | var tagHelperContent = new DefaultTagHelperContent(); 45 | tagHelperContent.SetContent(string.Empty); 46 | return Task.FromResult(tagHelperContent); 47 | }); 48 | 49 | _contextStub = new TagHelperContext(new TagHelperAttributeList(), 50 | new Dictionary(), 51 | Guid.NewGuid().ToString("N")); 52 | } 53 | 54 | [Test] 55 | public void Construction_IsSuccessful() 56 | { 57 | // Arrange 58 | 59 | 60 | // Act 61 | var instance = new RecaptchaTagHelper(_settingsMock.Object, _optionsMock.Object); 62 | 63 | // Assert 64 | Assert.NotNull(instance); 65 | _settingsMock.Verify(settings => settings.CurrentValue, Times.Once); 66 | _optionsMock.Verify(options => options.CurrentValue, Times.Once); 67 | } 68 | 69 | [Test] 70 | public void Constructor_ShouldThrow_WhenSettingsNull() 71 | { 72 | // Arrange 73 | 74 | 75 | // Act 76 | 77 | 78 | // Assert 79 | Assert.Throws(() => new RecaptchaTagHelper(null, _optionsMock.Object)); 80 | } 81 | 82 | [Test] 83 | public void Constructor_ShouldThrow_WhenOptionsNull() 84 | { 85 | // Arrange 86 | 87 | 88 | // Act 89 | 90 | 91 | // Assert 92 | Assert.Throws(() => new RecaptchaTagHelper(_settingsMock.Object, null)); 93 | } 94 | 95 | [Test] 96 | public void Constructor_ShouldSet_DefaultValues_FromGlobalOptions() 97 | { 98 | // Arrange 99 | _optionsMock.SetupGet(instance => instance.CurrentValue) 100 | .Returns(new RecaptchaOptions() 101 | { 102 | Theme = Theme.Dark, 103 | Size = Size.Compact 104 | }) 105 | .Verifiable(); 106 | 107 | // Act 108 | var tagHelper = new RecaptchaTagHelper(_settingsMock.Object, _optionsMock.Object); 109 | 110 | // Assert 111 | _optionsMock.Verify(options => options.CurrentValue, Times.Once); 112 | Assert.AreEqual(Theme.Dark, tagHelper.Theme); 113 | Assert.AreEqual(Size.Compact, tagHelper.Size); 114 | } 115 | 116 | [Test] 117 | public void Process_ShouldThrow_ArgumentNullException() 118 | { 119 | // Arrange 120 | var scriptTagHelper = new RecaptchaTagHelper(_settingsMock.Object, _optionsMock.Object); 121 | 122 | // Act 123 | 124 | 125 | // Assert 126 | Assert.Throws(() => scriptTagHelper.Process(_contextStub, null)); 127 | } 128 | 129 | [Test] 130 | public void Process_ShouldChangeTagTo_DivTag() 131 | { 132 | // Arrange 133 | var scriptTagHelper = new RecaptchaTagHelper(_settingsMock.Object, _optionsMock.Object); 134 | 135 | // Act 136 | scriptTagHelper.Process(_contextStub, _tagHelperOutputStub); 137 | 138 | // Assert 139 | Assert.AreEqual("div", _tagHelperOutputStub.TagName); 140 | } 141 | 142 | [Test] 143 | public void Process_ShouldChange_TagModeTo_StartTagAndEndTag() 144 | { 145 | // Arrange 146 | var scriptTagHelper = new RecaptchaTagHelper(_settingsMock.Object, _optionsMock.Object); 147 | _tagHelperOutputStub.TagMode = TagMode.SelfClosing; 148 | 149 | // Act 150 | scriptTagHelper.Process(_contextStub, _tagHelperOutputStub); 151 | 152 | // Assert 153 | Assert.AreEqual(TagMode.StartTagAndEndTag, _tagHelperOutputStub.TagMode); 154 | } 155 | 156 | [Test] 157 | public void Process_ShouldAdd_RecaptchaClass() 158 | { 159 | // Arrange 160 | var scriptTagHelper = new RecaptchaTagHelper(_settingsMock.Object, _optionsMock.Object); 161 | 162 | // Act 163 | scriptTagHelper.Process(_contextStub, _tagHelperOutputStub); 164 | 165 | // Assert 166 | Assert.IsTrue(_tagHelperOutputStub.Attributes.ContainsName("class")); 167 | Assert.AreEqual("g-recaptcha", _tagHelperOutputStub.Attributes["class"].Value); 168 | } 169 | 170 | [Test] 171 | public void Process_ShouldAdd_RecaptchaClassAnd_KeepExistingClasses() 172 | { 173 | // Arrange 174 | var scriptTagHelper = new RecaptchaTagHelper(_settingsMock.Object, _optionsMock.Object); 175 | _tagHelperOutputStub.Attributes.Add("class", "container text-center"); 176 | 177 | // Act 178 | scriptTagHelper.Process(_contextStub, _tagHelperOutputStub); 179 | var classes = _tagHelperOutputStub.Attributes["class"].Value.ToString().Split(" ", StringSplitOptions.RemoveEmptyEntries); 180 | 181 | // Assert 182 | Assert.AreEqual(3, classes.Length); 183 | Assert.IsTrue(classes.Contains("g-recaptcha")); 184 | Assert.IsTrue(classes.Contains("text-center")); 185 | Assert.IsTrue(classes.Contains("container")); 186 | } 187 | 188 | [Test] 189 | public void Process_ShouldReady_FromSettings_OnlyOnce() 190 | { 191 | // Arrange 192 | var scriptTagHelper = new RecaptchaTagHelper(_settingsMock.Object, _optionsMock.Object); 193 | 194 | // Act 195 | scriptTagHelper.Process(_contextStub, _tagHelperOutputStub); 196 | 197 | // Assert 198 | _settingsMock.Verify(mock => mock.CurrentValue, Times.Once); 199 | } 200 | 201 | [Test] 202 | public void Process_ShouldAdd_SiteKeyAttribute() 203 | { 204 | // Arrange 205 | var scriptTagHelper = new RecaptchaTagHelper(_settingsMock.Object, _optionsMock.Object); 206 | 207 | // Act 208 | scriptTagHelper.Process(_contextStub, _tagHelperOutputStub); 209 | 210 | // Assert 211 | Assert.IsTrue(_tagHelperOutputStub.Attributes.ContainsName("data-sitekey")); 212 | Assert.AreEqual(SiteKey, _tagHelperOutputStub.Attributes["data-sitekey"].Value.ToString()); 213 | } 214 | 215 | [Test] 216 | public void Process_ShouldOverrideExisting_SiteKeyAttribute() 217 | { 218 | // Arrange 219 | var scriptTagHelper = new RecaptchaTagHelper(_settingsMock.Object, _optionsMock.Object); 220 | _tagHelperOutputStub.Attributes.Add("data-sitekey", "false-key"); 221 | 222 | // Act 223 | scriptTagHelper.Process(_contextStub, _tagHelperOutputStub); 224 | 225 | // Assert 226 | Assert.AreEqual(1, _tagHelperOutputStub.Attributes.Count(attribute => attribute.Name == "data-sitekey")); 227 | Assert.AreEqual(SiteKey, _tagHelperOutputStub.Attributes["data-sitekey"].Value.ToString()); 228 | } 229 | 230 | [Test] 231 | public void Process_ShouldAdd_DataSizeAttribute() 232 | { 233 | // Arrange 234 | var scriptTagHelper = new RecaptchaTagHelper(_settingsMock.Object, _optionsMock.Object); 235 | 236 | // Act 237 | scriptTagHelper.Process(_contextStub, _tagHelperOutputStub); 238 | 239 | // Assert 240 | Assert.IsTrue(_tagHelperOutputStub.Attributes.ContainsName("data-size")); 241 | Assert.AreEqual(Size.Normal.ToString().ToLowerInvariant(), _tagHelperOutputStub.Attributes["data-size"].Value.ToString()); 242 | } 243 | 244 | [Test] 245 | public void Process_ShouldOverrideExisting_DataSizeAttribute() 246 | { 247 | // Arrange 248 | var scriptTagHelper = new RecaptchaTagHelper(_settingsMock.Object, _optionsMock.Object) 249 | { 250 | Size = Size.Compact 251 | }; 252 | 253 | _tagHelperOutputStub.Attributes.Add("data-size", "normal"); 254 | 255 | // Act 256 | scriptTagHelper.Process(_contextStub, _tagHelperOutputStub); 257 | 258 | // Assert 259 | Assert.AreEqual(1, _tagHelperOutputStub.Attributes.Count(attribute => attribute.Name == "data-size")); 260 | Assert.AreEqual(Size.Compact.ToString().ToLowerInvariant(), _tagHelperOutputStub.Attributes["data-size"].Value.ToString()); 261 | } 262 | 263 | [Test] 264 | public void Process_ShouldAdd_DataThemeAttribute() 265 | { 266 | // Arrange 267 | var scriptTagHelper = new RecaptchaTagHelper(_settingsMock.Object, _optionsMock.Object) 268 | { 269 | Size = Size.Compact 270 | }; 271 | 272 | // Act 273 | scriptTagHelper.Process(_contextStub, _tagHelperOutputStub); 274 | 275 | // Assert 276 | Assert.IsTrue(_tagHelperOutputStub.Attributes.ContainsName("data-theme")); 277 | Assert.AreEqual(Theme.Light.ToString().ToLowerInvariant(), _tagHelperOutputStub.Attributes["data-theme"].Value.ToString()); 278 | } 279 | 280 | [Test] 281 | public void Process_ShouldOverrideExisting_DataThemeAttribute() 282 | { 283 | // Arrange 284 | var scriptTagHelper = new RecaptchaTagHelper(_settingsMock.Object, _optionsMock.Object) 285 | { 286 | Theme = Theme.Dark 287 | }; 288 | 289 | _tagHelperOutputStub.Attributes.Add("data-theme", "light"); 290 | 291 | // Act 292 | scriptTagHelper.Process(_contextStub, _tagHelperOutputStub); 293 | 294 | // Assert 295 | Assert.AreEqual(1, _tagHelperOutputStub.Attributes.Count(attribute => attribute.Name == "data-theme")); 296 | Assert.AreEqual(Theme.Dark.ToString().ToLowerInvariant(), _tagHelperOutputStub.Attributes["data-theme"].Value.ToString()); 297 | } 298 | 299 | [Test] 300 | public void Process_ShouldAdd_DataTabindexAttribute() 301 | { 302 | // Arrange 303 | var scriptTagHelper = new RecaptchaTagHelper(_settingsMock.Object, _optionsMock.Object) 304 | { 305 | TabIndex = 2 306 | }; 307 | 308 | // Act 309 | scriptTagHelper.Process(_contextStub, _tagHelperOutputStub); 310 | 311 | // Assert 312 | Assert.IsTrue(_tagHelperOutputStub.Attributes.ContainsName("data-tabindex")); 313 | Assert.AreEqual(2, (int)_tagHelperOutputStub.Attributes["data-tabindex"].Value); 314 | } 315 | 316 | [Test] 317 | public void Process_ShouldOverrideExisting_DataTabIndexAttribute() 318 | { 319 | // Arrange 320 | var scriptTagHelper = new RecaptchaTagHelper(_settingsMock.Object, _optionsMock.Object) 321 | { 322 | TabIndex = 3 323 | }; 324 | 325 | _tagHelperOutputStub.Attributes.Add("data-tabindex", "1"); 326 | 327 | // Act 328 | scriptTagHelper.Process(_contextStub, _tagHelperOutputStub); 329 | 330 | // Assert 331 | Assert.AreEqual(1, _tagHelperOutputStub.Attributes.Count(attribute => attribute.Name == "data-tabindex")); 332 | Assert.AreEqual(3, (int)_tagHelperOutputStub.Attributes["data-tabindex"].Value); 333 | } 334 | 335 | [Test] 336 | public void Process_ShouldNotAdd_DataTabIndexAttribute_WhenTabIndexNull() 337 | { 338 | // Arrange 339 | var scriptTagHelper = new RecaptchaTagHelper(_settingsMock.Object, _optionsMock.Object); 340 | 341 | // Act 342 | scriptTagHelper.Process(_contextStub, _tagHelperOutputStub); 343 | 344 | // Assert 345 | Assert.IsFalse(_tagHelperOutputStub.Attributes.ContainsName("data-tabindex")); 346 | } 347 | 348 | [Test] 349 | public void Process_ShouldAdd_DataCallbackAttribute() 350 | { 351 | // Arrange 352 | var scriptTagHelper = new RecaptchaTagHelper(_settingsMock.Object, _optionsMock.Object) 353 | { 354 | Callback = "myCallback" 355 | }; 356 | 357 | // Act 358 | scriptTagHelper.Process(_contextStub, _tagHelperOutputStub); 359 | 360 | // Assert 361 | Assert.IsTrue(_tagHelperOutputStub.Attributes.ContainsName("data-callback")); 362 | Assert.AreEqual("myCallback", _tagHelperOutputStub.Attributes["data-callback"].Value.ToString()); 363 | } 364 | 365 | [Test] 366 | public void Process_ShouldOverrideExisting_DataCallbackAttribute() 367 | { 368 | // Arrange 369 | var scriptTagHelper = new RecaptchaTagHelper(_settingsMock.Object, _optionsMock.Object) 370 | { 371 | Callback = "myCallback" 372 | }; 373 | 374 | _tagHelperOutputStub.Attributes.Add("data-callback", "fake-callback"); 375 | 376 | // Act 377 | scriptTagHelper.Process(_contextStub, _tagHelperOutputStub); 378 | 379 | // Assert 380 | Assert.AreEqual(1, _tagHelperOutputStub.Attributes.Count(attribute => attribute.Name == "data-callback")); 381 | Assert.AreEqual("myCallback", _tagHelperOutputStub.Attributes["data-callback"].Value.ToString()); 382 | } 383 | 384 | [Test] 385 | public void Process_ShouldNotAdd_DataCallbackAttribute_WhenCallbackNullOrEmpty() 386 | { 387 | // Arrange 388 | var scriptTagHelper = new RecaptchaTagHelper(_settingsMock.Object, _optionsMock.Object); 389 | 390 | // Act 391 | scriptTagHelper.Process(_contextStub, _tagHelperOutputStub); 392 | 393 | // Assert 394 | Assert.IsFalse(_tagHelperOutputStub.Attributes.ContainsName("data-callback")); 395 | } 396 | 397 | [Test] 398 | public void Process_ShouldAdd_DataExpiredCallbackAttribute() 399 | { 400 | // Arrange 401 | var scriptTagHelper = new RecaptchaTagHelper(_settingsMock.Object, _optionsMock.Object) 402 | { 403 | ExpiredCallback = "myCallback" 404 | }; 405 | 406 | // Act 407 | scriptTagHelper.Process(_contextStub, _tagHelperOutputStub); 408 | 409 | // Assert 410 | Assert.IsTrue(_tagHelperOutputStub.Attributes.ContainsName("data-expired-callback")); 411 | Assert.AreEqual("myCallback", _tagHelperOutputStub.Attributes["data-expired-callback"].Value.ToString()); 412 | } 413 | 414 | [Test] 415 | public void Process_ShouldOverrideExisting_DataExpiredCallbackAttribute() 416 | { 417 | // Arrange 418 | var scriptTagHelper = new RecaptchaTagHelper(_settingsMock.Object, _optionsMock.Object) 419 | { 420 | ExpiredCallback = "myCallback" 421 | }; 422 | 423 | _tagHelperOutputStub.Attributes.Add("data-expired-callback", "fake-callback"); 424 | 425 | // Act 426 | scriptTagHelper.Process(_contextStub, _tagHelperOutputStub); 427 | 428 | // Assert 429 | Assert.AreEqual(1, _tagHelperOutputStub.Attributes.Count(attribute => attribute.Name == "data-expired-callback")); 430 | Assert.AreEqual("myCallback", _tagHelperOutputStub.Attributes["data-expired-callback"].Value.ToString()); 431 | } 432 | 433 | [Test] 434 | public void Process_ShouldNotAdd_DataExpiredCallbackAttribute_WhenExpiredCallbackNullOrEmpty() 435 | { 436 | // Arrange 437 | var scriptTagHelper = new RecaptchaTagHelper(_settingsMock.Object, _optionsMock.Object); 438 | 439 | // Act 440 | scriptTagHelper.Process(_contextStub, _tagHelperOutputStub); 441 | 442 | // Assert 443 | Assert.IsFalse(_tagHelperOutputStub.Attributes.ContainsName("data-expired-callback")); 444 | } 445 | 446 | [Test] 447 | public void Process_ShouldAdd_DataErrorCallbackAttribute() 448 | { 449 | // Arrange 450 | var scriptTagHelper = new RecaptchaTagHelper(_settingsMock.Object, _optionsMock.Object) 451 | { 452 | ErrorCallback = "myCallback" 453 | }; 454 | 455 | // Act 456 | scriptTagHelper.Process(_contextStub, _tagHelperOutputStub); 457 | 458 | // Assert 459 | Assert.IsTrue(_tagHelperOutputStub.Attributes.ContainsName("data-error-callback")); 460 | Assert.AreEqual("myCallback", _tagHelperOutputStub.Attributes["data-error-callback"].Value.ToString()); 461 | } 462 | 463 | [Test] 464 | public void Process_ShouldOverrideExisting_DataErrorCallbackAttribute() 465 | { 466 | // Arrange 467 | var scriptTagHelper = new RecaptchaTagHelper(_settingsMock.Object, _optionsMock.Object) 468 | { 469 | ErrorCallback = "myCallback" 470 | }; 471 | 472 | _tagHelperOutputStub.Attributes.Add("data-error-callback", "fake-callback"); 473 | 474 | // Act 475 | scriptTagHelper.Process(_contextStub, _tagHelperOutputStub); 476 | 477 | // Assert 478 | Assert.AreEqual(1, _tagHelperOutputStub.Attributes.Count(attribute => attribute.Name == "data-error-callback")); 479 | Assert.AreEqual("myCallback", _tagHelperOutputStub.Attributes["data-error-callback"].Value.ToString()); 480 | } 481 | 482 | [Test] 483 | public void Process_ShouldNotAdd_DataErrorCallbackAttribute_WhenErrorCallbackNullOrEmpty() 484 | { 485 | // Arrange 486 | var scriptTagHelper = new RecaptchaTagHelper(_settingsMock.Object, _optionsMock.Object); 487 | 488 | // Act 489 | scriptTagHelper.Process(_contextStub, _tagHelperOutputStub); 490 | 491 | // Assert 492 | Assert.IsFalse(_tagHelperOutputStub.Attributes.ContainsName("data-error-callback")); 493 | } 494 | } 495 | } 496 | -------------------------------------------------------------------------------- /tests/ReCaptcha.Tests/TagHelpers/RecaptchaV3TagHelperTests.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | using System.Threading.Tasks; 5 | using Griesoft.AspNetCore.ReCaptcha.Configuration; 6 | using Griesoft.AspNetCore.ReCaptcha.TagHelpers; 7 | using Microsoft.AspNetCore.Mvc.Razor.TagHelpers; 8 | using Microsoft.AspNetCore.Razor.TagHelpers; 9 | using Microsoft.Extensions.Options; 10 | using Moq; 11 | using NUnit.Framework; 12 | 13 | namespace ReCaptcha.Tests.TagHelpers 14 | { 15 | [TestFixture] 16 | public class RecaptchaV3TagHelperTests 17 | { 18 | private const string SiteKey = "unit_test_site_key"; 19 | 20 | private Mock> _settingsMock; 21 | private Mock _tagHelperComponentManagerMock; 22 | private Mock> _tagHelperComponentCollectionMock; 23 | private TagHelperOutput _tagHelperOutputStub; 24 | private TagHelperContext _contextStub; 25 | 26 | [SetUp] 27 | public void Initialize() 28 | { 29 | _settingsMock = new Mock>(); 30 | _settingsMock.SetupGet(instance => instance.CurrentValue) 31 | .Returns(new RecaptchaSettings() 32 | { 33 | SiteKey = SiteKey, 34 | SecretKey = string.Empty 35 | }) 36 | .Verifiable(); 37 | 38 | _tagHelperComponentCollectionMock = new Mock>(); 39 | _tagHelperComponentCollectionMock.Setup(instance => instance.Add(It.IsAny())) 40 | .Verifiable(); 41 | 42 | _tagHelperComponentManagerMock = new Mock(); 43 | _tagHelperComponentManagerMock.SetupGet(instance => instance.Components) 44 | .Returns(_tagHelperComponentCollectionMock.Object); 45 | 46 | _tagHelperOutputStub = new TagHelperOutput("recaptchav3", 47 | new TagHelperAttributeList(), (useCachedResult, htmlEncoder) => 48 | { 49 | var tagHelperContent = new DefaultTagHelperContent(); 50 | tagHelperContent.SetContent(string.Empty); 51 | return Task.FromResult(tagHelperContent); 52 | }); 53 | 54 | _contextStub = new TagHelperContext(new TagHelperAttributeList(), 55 | new Dictionary(), 56 | Guid.NewGuid().ToString("N")); 57 | } 58 | 59 | [Test] 60 | public void Construction_IsSuccessful() 61 | { 62 | // Arrange 63 | 64 | 65 | // Act 66 | var instance = new RecaptchaV3TagHelper(_settingsMock.Object, _tagHelperComponentManagerMock.Object); 67 | 68 | // Assert 69 | Assert.NotNull(instance); 70 | _settingsMock.Verify(settings => settings.CurrentValue, Times.Once); 71 | } 72 | 73 | [Test] 74 | public void Constructor_ShouldThrow_WhenSettingsNull() 75 | { 76 | // Arrange 77 | 78 | 79 | // Act 80 | 81 | 82 | // Assert 83 | Assert.Throws(() => new RecaptchaV3TagHelper(null, _tagHelperComponentManagerMock.Object)); 84 | } 85 | 86 | [Test] 87 | public void Constructor_ShouldThrow_WhenTagHelperComponentManagerNull() 88 | { 89 | // Arrange 90 | 91 | 92 | // Act 93 | 94 | 95 | // Assert 96 | Assert.Throws(() => new RecaptchaV3TagHelper(_settingsMock.Object, null)); 97 | } 98 | 99 | [Test] 100 | public void Process_ShouldThrow_ArgumentNullException() 101 | { 102 | // Arrange 103 | var scriptTagHelper = new RecaptchaV3TagHelper(_settingsMock.Object, _tagHelperComponentManagerMock.Object); 104 | 105 | // Act 106 | 107 | 108 | // Assert 109 | Assert.Throws(() => scriptTagHelper.Process(_contextStub, null)); 110 | } 111 | 112 | [Test] 113 | public void Process_ShouldThrow_InvalidOperationException_WhenCallbackAndFormIdNullOrEmpty() 114 | { 115 | // Arrange 116 | var nullCallbackTagHelper = new RecaptchaV3TagHelper(_settingsMock.Object, _tagHelperComponentManagerMock.Object) 117 | { 118 | Callback = null, 119 | FormId = null 120 | }; 121 | var emptyCallbackTagHelper = new RecaptchaV3TagHelper(_settingsMock.Object, _tagHelperComponentManagerMock.Object) 122 | { 123 | Callback = string.Empty, 124 | FormId = string.Empty 125 | }; 126 | 127 | // Act 128 | 129 | 130 | // Assert 131 | Assert.Throws(() => nullCallbackTagHelper.Process(_contextStub, _tagHelperOutputStub)); 132 | Assert.Throws(() => emptyCallbackTagHelper.Process(_contextStub, _tagHelperOutputStub)); 133 | } 134 | 135 | [Test] 136 | public void Process_ShouldThrow_InvalidOperationException_WhenActionIsNullOrEmpty() 137 | { 138 | // Arrange 139 | var nullCallbackTagHelper = new RecaptchaV3TagHelper(_settingsMock.Object, _tagHelperComponentManagerMock.Object) 140 | { 141 | FormId = "formId", 142 | Action = null 143 | }; 144 | var emptyCallbackTagHelper = new RecaptchaV3TagHelper(_settingsMock.Object, _tagHelperComponentManagerMock.Object) 145 | { 146 | FormId = "formId", 147 | Action = String.Empty 148 | }; 149 | 150 | // Act 151 | 152 | 153 | // Assert 154 | Assert.Throws(() => nullCallbackTagHelper.Process(_contextStub, _tagHelperOutputStub)); 155 | Assert.Throws(() => emptyCallbackTagHelper.Process(_contextStub, _tagHelperOutputStub)); 156 | } 157 | 158 | [Test] 159 | public void Process_ShouldSet_CallbackToDefaultCallback_WhenCallbackIsNullOrEmpty() 160 | { 161 | // Arrange 162 | var formId = $"formId"; 163 | var tag = new RecaptchaV3TagHelper(_settingsMock.Object, _tagHelperComponentManagerMock.Object) 164 | { 165 | Callback = null, 166 | FormId = formId, 167 | Action = "submit" 168 | }; 169 | 170 | // Act 171 | tag.Process(_contextStub, _tagHelperOutputStub); 172 | 173 | // Assert 174 | Assert.AreEqual(tag.Callback, $"submit{formId}"); 175 | } 176 | 177 | [Test] 178 | public void Process_ShouldAdd_CallbackScriptTagHelperComponent_WhenCallbackIsNullOrEmpty() 179 | { 180 | // Arrange 181 | var tag = new RecaptchaV3TagHelper(_settingsMock.Object, _tagHelperComponentManagerMock.Object) 182 | { 183 | Callback = null, 184 | FormId = "formId", 185 | Action = "submit" 186 | }; 187 | 188 | // Act 189 | tag.Process(_contextStub, _tagHelperOutputStub); 190 | 191 | // Assert 192 | _tagHelperComponentCollectionMock.Verify(); 193 | } 194 | 195 | [Test] 196 | public void Process_ShouldChange_TagModeTo_StartTagAndEndTag() 197 | { 198 | // Arrange 199 | var tagHelper = new RecaptchaV3TagHelper(_settingsMock.Object, _tagHelperComponentManagerMock.Object) 200 | { 201 | Callback = "submit", 202 | Action = "submit" 203 | }; 204 | _tagHelperOutputStub.TagMode = TagMode.SelfClosing; 205 | 206 | // Act 207 | tagHelper.Process(_contextStub, _tagHelperOutputStub); 208 | 209 | // Assert 210 | Assert.AreEqual(TagMode.StartTagAndEndTag, _tagHelperOutputStub.TagMode); 211 | } 212 | 213 | [Test] 214 | public void Process_ShouldChangeTag_ToButtonTag() 215 | { 216 | // Arrange 217 | var tagHelper = new RecaptchaV3TagHelper(_settingsMock.Object, _tagHelperComponentManagerMock.Object) 218 | { 219 | Callback = "submit", 220 | Action = "submit" 221 | }; 222 | 223 | // Act 224 | tagHelper.Process(_contextStub, _tagHelperOutputStub); 225 | 226 | // Assert 227 | Assert.AreEqual("button", _tagHelperOutputStub.TagName); 228 | } 229 | 230 | [Test] 231 | public void Process_ShouldAdd_RecaptchaClass() 232 | { 233 | // Arrange 234 | var tagHelper = new RecaptchaV3TagHelper(_settingsMock.Object, _tagHelperComponentManagerMock.Object) 235 | { 236 | Callback = "submit", 237 | Action = "submit" 238 | }; 239 | 240 | // Act 241 | tagHelper.Process(_contextStub, _tagHelperOutputStub); 242 | 243 | // Assert 244 | Assert.IsTrue(_tagHelperOutputStub.Attributes.ContainsName("class")); 245 | Assert.AreEqual("g-recaptcha", _tagHelperOutputStub.Attributes["class"].Value); 246 | } 247 | 248 | [Test] 249 | public void Process_ShouldAdd_RecaptchaClassAnd_KeepExistingClasses() 250 | { 251 | // Arrange 252 | var tagHelper = new RecaptchaV3TagHelper(_settingsMock.Object, _tagHelperComponentManagerMock.Object) 253 | { 254 | Callback = "submit", 255 | Action = "submit" 256 | }; 257 | _tagHelperOutputStub.Attributes.Add("class", "container text-center"); 258 | 259 | // Act 260 | tagHelper.Process(_contextStub, _tagHelperOutputStub); 261 | var classes = _tagHelperOutputStub.Attributes["class"].Value.ToString().Split(" ", StringSplitOptions.RemoveEmptyEntries); 262 | 263 | // Assert 264 | Assert.AreEqual(3, classes.Length); 265 | Assert.IsTrue(classes.Contains("g-recaptcha")); 266 | Assert.IsTrue(classes.Contains("text-center")); 267 | Assert.IsTrue(classes.Contains("container")); 268 | } 269 | 270 | [Test] 271 | public void Process_ShouldAdd_SiteKeyAttribute() 272 | { 273 | // Arrange 274 | var tagHelper = new RecaptchaV3TagHelper(_settingsMock.Object, _tagHelperComponentManagerMock.Object) 275 | { 276 | Callback = "myCallback", 277 | Action = "submit" 278 | }; 279 | 280 | // Act 281 | tagHelper.Process(_contextStub, _tagHelperOutputStub); 282 | 283 | // Assert 284 | Assert.IsTrue(_tagHelperOutputStub.Attributes.ContainsName("data-sitekey")); 285 | Assert.AreEqual(SiteKey, _tagHelperOutputStub.Attributes["data-sitekey"].Value.ToString()); 286 | } 287 | 288 | [Test] 289 | public void Process_ShouldOverrideExisting_SiteKeyAttribute() 290 | { 291 | // Arrange 292 | var tagHelper = new RecaptchaV3TagHelper(_settingsMock.Object, _tagHelperComponentManagerMock.Object) 293 | { 294 | Callback = "myCallback", 295 | Action = "submit" 296 | }; 297 | _tagHelperOutputStub.Attributes.Add("data-sitekey", "false-key"); 298 | 299 | // Act 300 | tagHelper.Process(_contextStub, _tagHelperOutputStub); 301 | 302 | // Assert 303 | Assert.AreEqual(1, _tagHelperOutputStub.Attributes.Count(attribute => attribute.Name == "data-sitekey")); 304 | Assert.AreEqual(SiteKey, _tagHelperOutputStub.Attributes["data-sitekey"].Value.ToString()); 305 | } 306 | 307 | [Test] 308 | public void Process_ShouldAdd_DataCallbackAttribute() 309 | { 310 | // Arrange 311 | var tagHelper = new RecaptchaV3TagHelper(_settingsMock.Object, _tagHelperComponentManagerMock.Object) 312 | { 313 | Callback = "myCallback", 314 | Action = "submit" 315 | }; 316 | 317 | // Act 318 | tagHelper.Process(_contextStub, _tagHelperOutputStub); 319 | 320 | // Assert 321 | Assert.IsTrue(_tagHelperOutputStub.Attributes.ContainsName("data-callback")); 322 | Assert.AreEqual("myCallback", _tagHelperOutputStub.Attributes["data-callback"].Value.ToString()); 323 | } 324 | 325 | [Test] 326 | public void Process_ShouldOverrideExisting_DataCallbackAttribute() 327 | { 328 | // Arrange 329 | var tagHelper = new RecaptchaV3TagHelper(_settingsMock.Object, _tagHelperComponentManagerMock.Object) 330 | { 331 | Callback = "myCallback", 332 | Action = "submit" 333 | }; 334 | 335 | _tagHelperOutputStub.Attributes.Add("data-callback", "fake-callback"); 336 | 337 | // Act 338 | tagHelper.Process(_contextStub, _tagHelperOutputStub); 339 | 340 | // Assert 341 | Assert.AreEqual(1, _tagHelperOutputStub.Attributes.Count(attribute => attribute.Name == "data-callback")); 342 | Assert.AreEqual("myCallback", _tagHelperOutputStub.Attributes["data-callback"].Value.ToString()); 343 | } 344 | 345 | [Test] 346 | public void Process_ShouldAdd_DataActionAttribute() 347 | { 348 | // Arrange 349 | var tagHelper = new RecaptchaV3TagHelper(_settingsMock.Object, _tagHelperComponentManagerMock.Object) 350 | { 351 | Callback = "myCallback", 352 | Action = "submit" 353 | }; 354 | 355 | // Act 356 | tagHelper.Process(_contextStub, _tagHelperOutputStub); 357 | 358 | // Assert 359 | Assert.IsTrue(_tagHelperOutputStub.Attributes.ContainsName("data-action")); 360 | Assert.AreEqual("submit", _tagHelperOutputStub.Attributes["data-action"].Value.ToString()); 361 | } 362 | 363 | [Test] 364 | public void Process_ShouldOverrideExisting_DataActionAttribute() 365 | { 366 | // Arrange 367 | var tagHelper = new RecaptchaV3TagHelper(_settingsMock.Object, _tagHelperComponentManagerMock.Object) 368 | { 369 | Callback = "myCallback", 370 | Action = "submit" 371 | }; 372 | 373 | _tagHelperOutputStub.Attributes.Add("data-action", "fake-action"); 374 | 375 | // Act 376 | tagHelper.Process(_contextStub, _tagHelperOutputStub); 377 | 378 | // Assert 379 | Assert.AreEqual(1, _tagHelperOutputStub.Attributes.Count(attribute => attribute.Name == "data-action")); 380 | Assert.AreEqual("submit", _tagHelperOutputStub.Attributes["data-action"].Value.ToString()); 381 | } 382 | } 383 | } 384 | -------------------------------------------------------------------------------- /tests/ReCaptcha.Tests/ValidateRecaptchaAttributeTests.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using Griesoft.AspNetCore.ReCaptcha; 3 | using Griesoft.AspNetCore.ReCaptcha.Configuration; 4 | using Griesoft.AspNetCore.ReCaptcha.Filters; 5 | using Microsoft.Extensions.Options; 6 | using Moq; 7 | using NUnit.Framework; 8 | 9 | namespace ReCaptcha.Tests 10 | { 11 | [TestFixture] 12 | public class ValidateRecaptchaAttributeTests 13 | { 14 | [Test(Description = "CreateInstance(...) should throw InvalidOperationException if the library services are not registered.")] 15 | public void CreateInstance_ShouldThrowWhen_ServicesNotRegistered() 16 | { 17 | // Arrange 18 | var servicesMock = new Mock(); 19 | servicesMock.Setup(provider => provider.GetService(typeof(ValidateRecaptchaFilter))) 20 | .Returns(null); 21 | var attribute = new ValidateRecaptchaAttribute(); 22 | 23 | // Act 24 | 25 | 26 | // Assert 27 | Assert.Throws(() => attribute.CreateInstance(servicesMock.Object)); 28 | } 29 | 30 | [Test(Description = "CreateInstance(...) should return a new instance of " + 31 | "ValidateRecaptchaFilter with the default value for the OnValidationFailedAction property.")] 32 | public void CreateInstance_ShouldReturn_ValidateRecaptchaFilter_WithDefaultOnValidationFailedAction() 33 | { 34 | // Arrange 35 | var optionsMock = new Mock>(); 36 | optionsMock.SetupGet(options => options.CurrentValue) 37 | .Returns(new RecaptchaOptions()); 38 | var servicesMock = new Mock(); 39 | servicesMock.Setup(provider => provider.GetService(typeof(IValidateRecaptchaFilter))) 40 | .Returns(new ValidateRecaptchaFilter(null, optionsMock.Object, null)) 41 | .Verifiable(); 42 | var attribute = new ValidateRecaptchaAttribute(); 43 | 44 | // Act 45 | var filterInstance = attribute.CreateInstance(servicesMock.Object); 46 | 47 | // Assert 48 | servicesMock.Verify(); 49 | Assert.IsNotNull(filterInstance); 50 | Assert.IsInstanceOf(filterInstance); 51 | Assert.AreEqual(ValidationFailedAction.Unspecified, (filterInstance as ValidateRecaptchaFilter).OnValidationFailedAction); 52 | } 53 | 54 | [Test(Description = "CreateInstance(...) should return a new instance of " + 55 | "ValidateRecaptchaFilter with the user set value for the OnValidationFailedAction property.")] 56 | public void CreateInstance_ShouldReturn_ValidateRecaptchaFilter_WithUserSetOnValidationFailedAction() 57 | { 58 | // Arrange 59 | var optionsMock = new Mock>(); 60 | optionsMock.SetupGet(options => options.CurrentValue) 61 | .Returns(new RecaptchaOptions()); 62 | var servicesMock = new Mock(); 63 | servicesMock.Setup(provider => provider.GetService(typeof(IValidateRecaptchaFilter))) 64 | .Returns(new ValidateRecaptchaFilter(null, optionsMock.Object, null)) 65 | .Verifiable(); 66 | var attribute = new ValidateRecaptchaAttribute 67 | { 68 | ValidationFailedAction = ValidationFailedAction.ContinueRequest 69 | }; 70 | 71 | // Act 72 | var filterInstance = attribute.CreateInstance(servicesMock.Object); 73 | 74 | // Assert 75 | servicesMock.Verify(); 76 | Assert.IsNotNull(filterInstance); 77 | Assert.IsInstanceOf(filterInstance); 78 | Assert.AreEqual(ValidationFailedAction.ContinueRequest, (filterInstance as ValidateRecaptchaFilter).OnValidationFailedAction); 79 | } 80 | 81 | [Test] 82 | public void CreateInstance_ShouldReturn_ValidateRecaptchaFilter_WithUserSetAction() 83 | { 84 | // Arrange 85 | var action = "submit"; 86 | var optionsMock = new Mock>(); 87 | optionsMock.SetupGet(options => options.CurrentValue) 88 | .Returns(new RecaptchaOptions()); 89 | var servicesMock = new Mock(); 90 | servicesMock.Setup(provider => provider.GetService(typeof(IValidateRecaptchaFilter))) 91 | .Returns(new ValidateRecaptchaFilter(null, optionsMock.Object, null)) 92 | .Verifiable(); 93 | var attribute = new ValidateRecaptchaAttribute 94 | { 95 | Action = action 96 | }; 97 | 98 | // Act 99 | var filterInstance = attribute.CreateInstance(servicesMock.Object); 100 | 101 | // Assert 102 | servicesMock.Verify(); 103 | Assert.IsNotNull(filterInstance); 104 | Assert.IsInstanceOf(filterInstance); 105 | Assert.AreEqual(action, (filterInstance as ValidateRecaptchaFilter).Action); 106 | } 107 | } 108 | } 109 | --------------------------------------------------------------------------------