Skip to content

Commit 3d658bb

Browse files
authored
Merge pull request #37 from EdiWang/feature/shared-key
Add Shared Key Stateless Captcha
2 parents b8488f0 + 342966a commit 3d658bb

19 files changed

+492
-207
lines changed

README.md

Lines changed: 73 additions & 148 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ services.AddSessionBasedCaptcha();
3737

3838
```csharp
3939
// Don't forget to add this line in your `Configure` method.
40-
app.UseSession();
40+
app.UseSession();
4141
```
4242

4343
or you can customize the options
@@ -186,7 +186,6 @@ public async Task<IActionResult> SomeAction(YourModelWithCaptchaCode model)
186186
}
187187
```
188188

189-
190189
## Stateless Captcha (Recommended for Scalable Applications)
191190

192191
**Advantages of Stateless Captcha:**
@@ -227,155 +226,14 @@ public class StatelessHomeModel
227226
}
228227
```
229228

230-
### 3. Example Controller
231-
232-
```csharp
233-
using Edi.Captcha.SampleApp.Models;
234-
using Microsoft.AspNetCore.Mvc;
235-
using System;
236-
using System.Diagnostics;
237-
238-
namespace Edi.Captcha.SampleApp.Controllers;
239-
240-
public class StatelessController(IStatelessCaptcha captcha) : Controller
241-
{
242-
public IActionResult Index()
243-
{
244-
return View(new StatelessHomeModel());
245-
}
246-
247-
[HttpPost]
248-
public IActionResult Index(StatelessHomeModel model)
249-
{
250-
if (ModelState.IsValid)
251-
{
252-
bool isValidCaptcha = captcha.Validate(model.CaptchaCode, model.CaptchaToken);
253-
return Content(isValidCaptcha ? "Success - Stateless captcha validated!" : "Invalid captcha code");
254-
}
255-
256-
return BadRequest();
257-
}
258-
259-
[Route("get-stateless-captcha")]
260-
public IActionResult GetStatelessCaptcha()
261-
{
262-
var result = captcha.GenerateCaptcha(100, 36);
263-
264-
return Json(new {
265-
token = result.Token,
266-
imageBase64 = Convert.ToBase64String(result.ImageBytes)
267-
});
268-
}
269-
270-
[ResponseCache(Duration = 0, Location = ResponseCacheLocation.None, NoStore = true)]
271-
public IActionResult Error()
272-
{
273-
return View(new ErrorViewModel { RequestId = Activity.Current?.Id ?? HttpContext.TraceIdentifier });
274-
}
275-
}
276-
```
277-
278-
### 4. Example View
279-
280-
```razor
281-
@model StatelessHomeModel
282-
@{
283-
ViewData["Title"] = "Stateless Captcha Example";
284-
}
285-
286-
<div class="text-center">
287-
<h1 class="display-4">Stateless Captcha Example</h1>
288-
<p>This example shows how to use stateless captcha that works in clustered environments.</p>
289-
</div>
290-
291-
<div class="row">
292-
<div class="col-md-6 offset-md-3">
293-
<div class="card">
294-
<div class="card-header">
295-
<h5>Stateless Captcha Form</h5>
296-
</div>
297-
<div class="card-body">
298-
<form asp-action="Index" method="post" id="stateless-form">
299-
<div class="form-group mb-3">
300-
<label>Captcha Image:</label>
301-
<div class="d-flex align-items-center">
302-
<img id="captcha-image" src="" alt="Captcha" class="me-2" style="border: 1px solid #ccc;" />
303-
<button type="button" class="btn btn-sm btn-outline-secondary" onclick="refreshCaptcha()">
304-
🔄 Refresh
305-
</button>
306-
</div>
307-
<small class="form-text text-muted">Click refresh to get a new captcha</small>
308-
</div>
309-
310-
<div class="form-group mb-3">
311-
<label asp-for="CaptchaCode">Enter Captcha Code:</label>
312-
<input asp-for="CaptchaCode" class="form-control" placeholder="Enter the code from image" autocomplete="off" />
313-
<span asp-validation-for="CaptchaCode" class="text-danger"></span>
314-
</div>
315-
316-
<input type="hidden" asp-for="CaptchaToken" id="captcha-token" />
317-
318-
<div class="form-group">
319-
<button type="submit" class="btn btn-primary">Submit</button>
320-
<a asp-controller="Home" asp-action="Index" class="btn btn-secondary">Session-based Example</a>
321-
</div>
322-
</form>
323-
324-
<div class="mt-4">
325-
<h6>Advantages of Stateless Captcha:</h6>
326-
<ul class="small">
327-
<li>✅ Works in clustered/load-balanced environments</li>
328-
<li>✅ No server-side session storage required</li>
329-
<li>✅ Built-in expiration through encryption</li>
330-
<li>✅ Secure token-based validation</li>
331-
<li>✅ Better scalability</li>
332-
<li>✅ Single API call for both token and image</li>
333-
</ul>
334-
</div>
335-
</div>
336-
</div>
337-
</div>
338-
</div>
229+
### 3. Example Controller and View
339230

340-
<script>
341-
async function refreshCaptcha() {
342-
try {
343-
const response = await fetch('/get-stateless-captcha');
344-
const data = await response.json();
345-
346-
// Set the token for validation
347-
document.getElementById('captcha-token').value = data.token;
348-
349-
// Set the image source using base64 data
350-
document.getElementById('captcha-image').src = `data:image/png;base64,${data.imageBase64}`;
351-
352-
// Clear the input
353-
document.getElementById('CaptchaCode').value = '';
354-
} catch (error) {
355-
console.error('Error refreshing captcha:', error);
356-
alert('Failed to load captcha. Please try again.');
357-
}
358-
}
231+
See: [src\Edi.Captcha.SampleApp\Controllers\StatelessController.cs](src/Edi.Captcha.SampleApp/Controllers/StatelessController.cs) and [src\Edi.Captcha.SampleApp\Views\Stateless\Index.cshtml](src/Edi.Captcha.SampleApp/Views/Stateless/Index.cshtml) for a complete example.
359232

360-
// Initialize captcha on page load
361-
document.addEventListener('DOMContentLoaded', function() {
362-
refreshCaptcha();
363-
});
364-
</script>
365-
366-
@section Scripts {
367-
@{await Html.RenderPartialAsync("_ValidationScriptsPartial");}
368-
}
369-
```
370-
371-
## Cluster/Load Balancer Configuration
233+
### Cluster/Load Balancer Configuration
372234

373235
⚠️ **Important for Production Deployments**: The stateless captcha uses ASP.NET Core's Data Protection API for token encryption. In clustered environments or behind load balancers, you **must** configure shared data protection keys to ensure captcha tokens can be validated on any server.
374236

375-
### Configure Shared Data Protection Keys
376-
377-
Choose one of the following approaches based on your infrastructure:
378-
379237
#### Option 1: File System (Network Share)
380238
```csharp
381239
public void ConfigureServices(IServiceCollection services)
@@ -436,11 +294,11 @@ public void ConfigureServices(IServiceCollection services)
436294
}
437295
```
438296

439-
### Single Server Deployment
297+
#### Single Server Deployment
440298

441299
For single server deployments, no additional configuration is required. The default Data Protection configuration will work correctly.
442300

443-
### Testing Cluster Configuration
301+
#### Testing Cluster Configuration
444302

445303
To verify your cluster configuration is working:
446304

@@ -450,3 +308,70 @@ To verify your cluster configuration is working:
450308

451309
If validation fails with properly entered captcha codes, check your Data Protection configuration.
452310

311+
## Shared Key Stateless Captcha (Recommended for Scalable Applications without DPAPI)
312+
313+
**When to use Shared Key Stateless Captcha:**
314+
- ✅ Full control over encryption keys
315+
- ✅ Works without ASP.NET Core Data Protection API
316+
- ✅ Simpler cluster configuration
317+
- ✅ Custom key rotation strategies
318+
- ✅ Works across different application frameworks
319+
- ✅ No dependency on external storage for keys
320+
321+
### 1. Register in DI with Shared Key
322+
323+
```csharp
324+
services.AddSharedKeyStatelessCaptcha(options =>
325+
{
326+
options.SharedKey = "your-32-byte-base64-encoded-key"; // Generate securely
327+
options.FontStyle = FontStyle.Bold;
328+
options.DrawLines = true;
329+
options.TokenExpiration = TimeSpan.FromMinutes(5);
330+
});
331+
```
332+
333+
### 2. Generate Secure Shared Key
334+
335+
**Important**: Use a cryptographically secure random key. Here's how to generate one:
336+
337+
```csharp
338+
// Generate a secure 256-bit key (one-time setup)
339+
using (var rng = RandomNumberGenerator.Create())
340+
{
341+
var keyBytes = new byte[32]; // 256 bits
342+
rng.GetBytes(keyBytes);
343+
var base64Key = Convert.ToBase64String(keyBytes);
344+
Console.WriteLine($"Shared Key: {base64Key}");
345+
}
346+
```
347+
348+
### 3. Configuration Options
349+
350+
#### Configuration File (appsettings.json)
351+
```json
352+
{
353+
"CaptchaSettings": {
354+
"SharedKey": "your-generated-base64-key-here",
355+
"TokenExpirationMinutes": 5
356+
}
357+
}
358+
```
359+
360+
```csharp
361+
public void ConfigureServices(IServiceCollection services)
362+
{
363+
var captchaKey = Configuration["CaptchaSettings:SharedKey"];
364+
var expirationMinutes = Configuration.GetValue<int>("CaptchaSettings:TokenExpirationMinutes", 5);
365+
366+
services.AddSharedKeyStatelessCaptcha(options =>
367+
{
368+
options.SharedKey = captchaKey;
369+
options.TokenExpiration = TimeSpan.FromMinutes(expirationMinutes);
370+
// Other options...
371+
});
372+
}
373+
```
374+
375+
### 4. Example Controller and View
376+
377+
See: [src\Edi.Captcha.SampleApp\Controllers\SharedKeyStatelessController.cs](src/Edi.Captcha.SampleApp/Controllers/SharedKeyStatelessController.cs) and [src\Edi.Captcha.SampleApp\Views\SharedKeyStateless\Index.cshtml](src/Edi.Captcha.SampleApp/Views/SharedKeyStateless/Index.cshtml) for a complete example.

src/Edi.Captcha.SampleApp/Controllers/HomeController.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,11 @@ public class HomeController(ISessionBasedCaptcha captcha) : Controller
88
{
99
public IActionResult Index()
1010
{
11-
return View(new HomeModel());
11+
return View(new SessionCaptchaModel());
1212
}
1313

1414
[HttpPost]
15-
public IActionResult Index(HomeModel model)
15+
public IActionResult Index(SessionCaptchaModel model)
1616
{
1717
if (ModelState.IsValid)
1818
{
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
using Edi.Captcha.SampleApp.Models;
2+
using Microsoft.AspNetCore.Mvc;
3+
using System;
4+
using System.Collections.Generic;
5+
using System.Diagnostics;
6+
using System.Linq;
7+
8+
namespace Edi.Captcha.SampleApp.Controllers;
9+
10+
public class SharedKeyStatelessController : Controller
11+
{
12+
private readonly IStatelessCaptcha _captcha;
13+
14+
public SharedKeyStatelessController(IEnumerable<IStatelessCaptcha> captchaServices)
15+
{
16+
// Get the last registered IStatelessCaptcha service, which should be SharedKeyStatelessCaptcha
17+
_captcha = captchaServices.Last();
18+
}
19+
20+
public IActionResult Index()
21+
{
22+
return View(new SharedKeyCaptchaModel());
23+
}
24+
25+
[HttpPost]
26+
public IActionResult Index(SharedKeyCaptchaModel model)
27+
{
28+
if (ModelState.IsValid)
29+
{
30+
bool isValidCaptcha = _captcha.Validate(model.CaptchaCode, model.CaptchaToken);
31+
return Content(isValidCaptcha ? "Success - Shared Key Stateless captcha validated!" : "Invalid captcha code");
32+
}
33+
34+
return BadRequest();
35+
}
36+
37+
[Route("get-shared-key-stateless-captcha")]
38+
public IActionResult GetSharedKeyStatelessCaptcha()
39+
{
40+
var result = _captcha.GenerateCaptcha(100, 36);
41+
42+
return Json(new
43+
{
44+
token = result.Token,
45+
imageBase64 = Convert.ToBase64String(result.ImageBytes)
46+
});
47+
}
48+
49+
[ResponseCache(Duration = 0, Location = ResponseCacheLocation.None, NoStore = true)]
50+
public IActionResult Error()
51+
{
52+
return View(new ErrorViewModel { RequestId = Activity.Current?.Id ?? HttpContext.TraceIdentifier });
53+
}
54+
}

src/Edi.Captcha.SampleApp/Controllers/StatelessController.cs

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,11 +9,11 @@ public class StatelessController(IStatelessCaptcha captcha) : Controller
99
{
1010
public IActionResult Index()
1111
{
12-
return View(new StatelessHomeModel());
12+
return View(new StatelessCaptchaModel());
1313
}
1414

1515
[HttpPost]
16-
public IActionResult Index(StatelessHomeModel model)
16+
public IActionResult Index(StatelessCaptchaModel model)
1717
{
1818
if (ModelState.IsValid)
1919
{
@@ -28,9 +28,10 @@ public IActionResult Index(StatelessHomeModel model)
2828
public IActionResult GetStatelessCaptcha()
2929
{
3030
var result = captcha.GenerateCaptcha(100, 36);
31-
32-
return Json(new {
33-
token = result.Token,
31+
32+
return Json(new
33+
{
34+
token = result.Token,
3435
imageBase64 = Convert.ToBase64String(result.ImageBytes)
3536
});
3637
}

src/Edi.Captcha.SampleApp/Models/HomeModel.cs renamed to src/Edi.Captcha.SampleApp/Models/SessionCaptchaModel.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
namespace Edi.Captcha.SampleApp.Models;
44

5-
public class HomeModel
5+
public class SessionCaptchaModel
66
{
77
[Required]
88
[StringLength(4)]

src/Edi.Captcha.SampleApp/Models/StatelessHomeModel.cs renamed to src/Edi.Captcha.SampleApp/Models/SharedKeyCaptchaModel.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,11 @@
22

33
namespace Edi.Captcha.SampleApp.Models;
44

5-
public class StatelessHomeModel
5+
public class SharedKeyCaptchaModel
66
{
77
[Required]
88
[StringLength(4)]
99
public string CaptchaCode { get; set; }
10-
10+
1111
public string CaptchaToken { get; set; }
1212
}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
using System.ComponentModel.DataAnnotations;
2+
3+
namespace Edi.Captcha.SampleApp.Models;
4+
5+
public class StatelessCaptchaModel
6+
{
7+
[Required]
8+
[StringLength(4)]
9+
public string CaptchaCode { get; set; }
10+
11+
public string CaptchaToken { get; set; }
12+
}

0 commit comments

Comments
 (0)