|
| 1 | +# Captcha |
| 2 | + |
| 3 | +A human check in front of a form. One component over four providers, chosen **by name at registration** and swapped without touching the markup. |
| 4 | + |
| 5 | +| | | |
| 6 | +| --- | --- | |
| 7 | +| Host | **Blazor only** — WebAssembly, Server, Hybrid | |
| 8 | +| Package | `Shiny.Blazor.Controls` (core — no add-on) | |
| 9 | +| Namespace | `Shiny.Blazor.Controls.Captchas` — add it to the **app's** `_Imports.razor`; the package's own imports do not reach consumers | |
| 10 | + |
| 11 | +**There is no MAUI `Captcha`.** The hosted providers are browser widgets — a script that renders into a DOM element — and the local challenge draws to an HTML canvas. Never emit `<Captcha>` in XAML. A MAUI app hosting Blazor in a `BlazorWebView` uses the local challenge as-is; the hosted providers need a real origin their site key is registered for. |
| 12 | + |
| 13 | +## A token is not a verdict |
| 14 | + |
| 15 | +The component hands you a **response token** in `State.Response`. For the hosted providers it is your **server** that posts that token, with your *secret* key, to the provider's siteverify endpoint. Trusting `IsSolved` alone is trusting the client, which is the thing a captcha exists to avoid. |
| 16 | + |
| 17 | +Never put a secret key in the client project — `UseReCaptcha`/`UseHCaptcha`/`UseTurnstile` take the **public site key** only. If asked for "the captcha key", that is the one. |
| 18 | + |
| 19 | +## Setup |
| 20 | + |
| 21 | +Registration is **optional**. With nothing registered, `<Captcha />` renders the local challenge with its defaults. |
| 22 | + |
| 23 | +```csharp |
| 24 | +// umbrella |
| 25 | +builder.Services.AddShinyControls(cfg => cfg |
| 26 | + .ConfigureCaptcha(c => c.UseTurnstile("0x4AAA...")) |
| 27 | +); |
| 28 | + |
| 29 | +// or on its own |
| 30 | +builder.Services.AddShinyCaptcha(c => c.UseTurnstile("0x4AAA...")); |
| 31 | +``` |
| 32 | + |
| 33 | +Register as many as you like — the component picks one by name, and absent a name uses `SetDefaultProvider`, then the first registered, then the local challenge. That is what makes "Turnstile in production, local challenge in the dev build" a config change rather than a markup change. |
| 34 | + |
| 35 | +```csharp |
| 36 | +builder.Services.AddShinyCaptcha(c => c |
| 37 | + .UseTurnstile(siteKey) |
| 38 | + .UseLocal(o => o.Mode = LocalCaptchaMode.Math, name: "math") // a second, named local challenge |
| 39 | + .SetDefaultProvider("turnstile") |
| 40 | + .SetTheme(CaptchaTheme.Auto) |
| 41 | + .SetSize(CaptchaSize.Normal) |
| 42 | + .SetLanguage("fr") |
| 43 | +); |
| 44 | +``` |
| 45 | + |
| 46 | +| Registration | Provider name | Notes | |
| 47 | +| --- | --- | --- | |
| 48 | +| `UseLocal(configure?, name?)` | `local`, or the name given | Self-hosted; the un-named one is also the fallback | |
| 49 | +| `UseReCaptcha(siteKey)` | `recaptcha` | Google | |
| 50 | +| `UseHCaptcha(siteKey)` | `hcaptcha` | | |
| 51 | +| `UseTurnstile(siteKey)` | `turnstile` | Cloudflare; the only one supporting `Flexible` | |
| 52 | +| `UseProvider<T>()` / `UseProvider(instance)` | whatever `Name` returns | | |
| 53 | + |
| 54 | +A `Provider="..."` naming something that is **not** registered renders a visible "No captcha provider named … is registered" alert rather than silently dropping to a weaker check. That is a wiring mistake, not a fallback. |
| 55 | + |
| 56 | +## Usage |
| 57 | + |
| 58 | +```razor |
| 59 | +<Captcha @ref="captcha" ValidChanged="v => canSubmit = v" /> |
| 60 | +
|
| 61 | +<button disabled="@(!canSubmit)" @onclick="SubmitAsync">Sign up</button> |
| 62 | +
|
| 63 | +@code { |
| 64 | + Captcha? captcha; |
| 65 | + bool canSubmit; |
| 66 | +
|
| 67 | + async Task SubmitAsync() |
| 68 | + { |
| 69 | + var token = this.captcha!.Response; // hand this to your server |
| 70 | + // ... post the form ... |
| 71 | +
|
| 72 | + // a spent token cannot be replayed — start a fresh challenge after a failed submit |
| 73 | + await this.captcha.ResetAsync(); |
| 74 | + } |
| 75 | +} |
| 76 | +``` |
| 77 | + |
| 78 | +`ValidChanged` is the property to gate a submit button on. It flips both ways — a solved challenge that later **expires** flips it back — which polling `IsSolved` on render would miss. |
| 79 | + |
| 80 | +## Server-side validation |
| 81 | + |
| 82 | +`Validate` is called with the fresh token the moment the widget solves, and decides whether the state counts as valid. Return `false` and the component stays invalid — and, unless `ResetOnFailedValidation="false"`, throws the challenge away and starts a new one, because a token your server rejected is spent either way. A `Validate` that **throws** is treated as a rejection and the exception message is shown as the widget error. |
| 83 | + |
| 84 | +```razor |
| 85 | +<Captcha Validate="VerifyAsync" Solved="OnSolved" /> |
| 86 | +
|
| 87 | +@code { |
| 88 | + void OnSolved(CaptchaState state) { /* Valid is already true here */ } |
| 89 | +
|
| 90 | + async Task<bool> VerifyAsync(CaptchaState state) |
| 91 | + { |
| 92 | + // your endpoint holds the secret key and posts to the provider's siteverify |
| 93 | + var response = await http.PostAsJsonAsync("api/captcha/verify", new { state.Response }); |
| 94 | + return response.IsSuccessStatusCode; |
| 95 | + } |
| 96 | +} |
| 97 | +``` |
| 98 | + |
| 99 | +`Solved` fires **after** `Validate` has agreed, so a handler on it can assume the check passed. |
| 100 | + |
| 101 | +## Invisible mode |
| 102 | + |
| 103 | +An invisible provider scores the session in the background and renders no challenge, so nothing ever solves on its own. Call `ExecuteAsync()` from the submit handler and continue in `Solved`. |
| 104 | + |
| 105 | +```razor |
| 106 | +<Captcha @ref="captcha" Size="CaptchaSize.Invisible" Solved="OnSolvedAsync" /> |
| 107 | +
|
| 108 | +@code { |
| 109 | + Task SubmitAsync() => this.captcha!.ExecuteAsync(); // work continues in OnSolvedAsync |
| 110 | +} |
| 111 | +``` |
| 112 | + |
| 113 | +`BadgePosition` places the provider's badge — `BottomEnd` (default), `BottomStart`, or `Inline` to render it in the flow. The local provider has nothing to score and ignores `Invisible` entirely; `ExecuteAsync()` is a no-op for visible widgets. |
| 114 | + |
| 115 | +## The local challenge |
| 116 | + |
| 117 | +Self-hosted: no account, no site key, no third-party script, works offline and inside a `BlazorWebView`. |
| 118 | + |
| 119 | +**Say this when suggesting it:** it is a speed bump, not a security boundary. The challenge is generated *and checked* in the browser, so anything with a debugger attached can read the answer out of memory. It stops naive form-fill bots and nothing more. For a public form worth attacking, register a hosted provider and verify the token on the server. |
| 120 | + |
| 121 | +```csharp |
| 122 | +builder.Services.AddShinyCaptcha(c => c.UseLocal(o => |
| 123 | +{ |
| 124 | + o.Mode = LocalCaptchaMode.Math; |
| 125 | + o.ExpirySeconds = 60; |
| 126 | +})); |
| 127 | +``` |
| 128 | + |
| 129 | +| Option | Default | | |
| 130 | +| --- | --- | --- | |
| 131 | +| `Mode` | `Text` | `Text` draws distorted characters to a canvas; `Math` asks a small sum | |
| 132 | +| `Length` | `5` | Characters in the text challenge, clamped to 3–12 | |
| 133 | +| `CharacterSet` | `ABCDEFGHJKMNPQRSTUVWXYZ23456789` | Look-alikes (`0 O 1 I L`) already removed | |
| 134 | +| `CaseSensitive` | `false` | | |
| 135 | +| `Width` / `Height` | `180` / `60` | Canvas size in CSS pixels | |
| 136 | +| `ExpirySeconds` | `120` | `Expired` then fires and the widget resets. `0` or less disables expiry | |
| 137 | +| `MaxAttempts` | `3` | Wrong answers before the challenge is redrawn | |
| 138 | +| `Prompt` / `IncorrectText` / `RefreshText` / `PlaceholderText` | — | Wording, for localisation | |
| 139 | + |
| 140 | +`LocalCaptchaMode.Math` renders **real text**, not a canvas, so a screen reader can read it — that is the whole reason it exists. Prefer it, or offer it alongside, whenever accessibility is in scope. The answer is checked as soon as enough characters are typed (Enter forces a check), a wrong answer shakes the field, and `MaxAttempts` wrong answers redraw the challenge. |
| 141 | + |
| 142 | +Its token is an opaque `local.<guid>` — the shape matches the hosted providers so the calling code does not change, but it proves only that *this browser* solved the challenge. There is nothing to verify it against, so do not write a `Validate` that pretends to. |
| 143 | + |
| 144 | +## Parameters |
| 145 | + |
| 146 | +| Parameter | Type | Default | | |
| 147 | +| --- | --- | --- | --- | |
| 148 | +| `Provider` | `string?` | `null` | Which registered provider. Null follows the configured default | |
| 149 | +| `Theme` | `CaptchaTheme?` | configured | `Auto` (from `prefers-color-scheme`), `Light`, `Dark` | |
| 150 | +| `Size` | `CaptchaSize?` | configured | `Normal`, `Compact`, `Invisible`, `Flexible` (**Turnstile only**, falls back to `Normal`) | |
| 151 | +| `LanguageCode` | `string?` | `null` | Two-letter code. Null follows the browser | |
| 152 | +| `BadgePosition` | `CaptchaBadgePosition` | `BottomEnd` | Invisible mode only | |
| 153 | +| `ShowError` | `bool` | `true` | Renders widget failures under the widget | |
| 154 | +| `ResetOnFailedValidation` | `bool` | `true` | | |
| 155 | +| `Validate` | `Func<CaptchaState, Task<bool>>?` | `null` | | |
| 156 | +| `CssClass` | `string?` | `null` | Extra classes on the host element; unmatched attributes splat onto it too | |
| 157 | + |
| 158 | +**Events** — `Solved(CaptchaState)`, `Expired`, `Errored(string)` (script blocked, bad site key, network gone), `ValidChanged(bool)`. |
| 159 | + |
| 160 | +**Members** — `State` (never null), `IsSolved`, `Response`, `ResetAsync()`, `ExecuteAsync()`. |
| 161 | + |
| 162 | +`CaptchaState` is a record: `(bool Valid, string? Response, string ProviderName)`. |
| 163 | + |
| 164 | +## A provider the package does not ship |
| 165 | + |
| 166 | +The built-ins are only `ICaptchaProvider` implementations registered by name. For another hosted one, subclass `RemoteCaptchaProvider` and supply a descriptor — one shared JS driver does script loading, widget lifetime, callbacks, reset and execute. |
| 167 | + |
| 168 | +```csharp |
| 169 | +public class MyCaptchaProvider(string siteKey) : RemoteCaptchaProvider |
| 170 | +{ |
| 171 | + public override RemoteCaptchaDescriptor Descriptor { get; } = new() |
| 172 | + { |
| 173 | + Name = "mycaptcha", |
| 174 | + ScriptUrl = "https://example.com/api.js?render=explicit{lang}", // {lang} is substituted or dropped |
| 175 | + GlobalName = "mycaptcha", |
| 176 | + SiteKey = siteKey, |
| 177 | + UseReadyCallback = false, // true when the global exposes ready(cb), as reCAPTCHA does |
| 178 | + SupportsBadge = true, // render() takes a badge option |
| 179 | + LanguageAsRenderOption = false, // true puts the language in render() instead of the URL |
| 180 | + SupportedSizes = ["normal", "compact"] |
| 181 | + }; |
| 182 | +} |
| 183 | + |
| 184 | +builder.Services.AddShinyCaptcha(c => c.UseProvider(new MyCaptchaProvider(siteKey))); |
| 185 | +``` |
| 186 | + |
| 187 | +For something that is not a script-and-global widget at all, implement `ICaptchaProvider` directly: return a `RenderFragment` from `Render(CaptchaRenderContext)`, raise `OnSolved`/`OnExpired`/`OnErrored`, and call `OnWidgetReady(this)` with an `ICaptchaWidget` so `ResetAsync`/`ExecuteAsync` have something to talk to. |
| 188 | + |
| 189 | +## Gotchas |
| 190 | + |
| 191 | +- **Never ship a secret key to the client.** Site key in the app, secret key on the server. |
| 192 | +- `IsSolved` alone is not verification for a hosted provider — pair it with `Validate` and a siteverify call. |
| 193 | +- Reset after a failed submit. A token is single-use at the provider. |
| 194 | +- Bind a submit button to `ValidChanged`, not to a value read once — challenges expire. |
| 195 | +- The local challenge is client-side only; do not present it as protection for a public form. |
| 196 | +- Blazor only. No `<Captcha>` in XAML, no `UseShinyCaptcha()` on `MauiAppBuilder`. |
0 commit comments