Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions CLAUDE.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -75,6 +75,7 @@ The [SpringUserFrameworkDemoApp](https://github.com/devondragon/SpringUserFramew
com.digitalsanctuary.spring.user
├── api/ # REST endpoints (UserAPI)
├── audit/ # Audit logging system
├── captcha/ # Optional CAPTCHA (Turnstile) on unauthenticated email-sending API actions
├── controller/ # MVC controllers for HTML pages
├── dev/ # Dev login auto-configuration (local profile only)
├── dto/ # Data transfer objects
Expand DownExpand Up@@ -119,12 +120,13 @@ com.digitalsanctuary.spring.user
- `BaseSessionProfile<T>` - Session-scoped profile access
- `UserPreDeleteEvent` - Listen for user deletion to clean up related data
- `AuthenticationEntryPoint` - Override via `@ConditionalOnMissingBean` to customize session expiry behavior
- `CaptchaService` - Supply a bean to replace the built-in Turnstile CAPTCHA provider

### Auto-Configuration

- Entry point: `META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports` → `UserConfiguration`
- Entry point: `META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports` → `UserConfiguration` plus feature auto-configurations (audit mail, CAPTCHA, security beans, security filter chain)
- `UserAutoConfigurationRegistrar` dynamically registers the library package for entity/repository scanning
- No conditional annotations — all features load, controlled by `user.*` properties
- Most features load unconditionally and are controlled by `user.*` properties. The CAPTCHA support (`captcha` package) is the exception: it is gated by `@ConditionalOnProperty`/`@ConditionalOnClass` so it stays inert and dependency-free when disabled.

### Startup Behavior

Expand All@@ -141,6 +143,7 @@ com.digitalsanctuary.spring.user

All configuration uses `user.*` prefix in application.yml. Key property groups:
- `user.security.*` - URIs, default action (allow/deny), bcrypt strength, lockout settings, testHashTime
- `user.security.captcha.*` - Optional CAPTCHA (Cloudflare Turnstile) on unauthenticated email-sending API actions (enabled, provider, allowUnusableProvider, protect.*)
- `user.registration.*` - Email verification toggle, OAuth provider toggles
- `user.mail.*` - Email sender settings (fromAddress)
- `user.audit.*` - Audit logging (logFilePath, flushOnWrite, logEvents, maxQueryResults)
Expand Down
27 changes: 27 additions & 0 deletions CONFIG.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -92,6 +92,33 @@ Password-reset and verification emails contain a link back to your application.

When neither `appUrl` nor `trustedHosts` is set, links are built from the request host (backward-compatible behavior) and a startup warning is logged.

### CAPTCHA Protection (Cloudflare Turnstile)

Optional CAPTCHA verification on the framework's unauthenticated, email-sending API actions (`POST /user/registration`, `POST /user/resetPassword`, `POST /user/resendRegistrationToken`). Disabled by default: no CAPTCHA interceptor or provider beans are registered and behavior is unchanged until you opt in.

- **Enabled (`user.security.captcha.enabled`)**: Master switch. When `false` (default), no CAPTCHA interceptor or provider beans are registered and no requests are checked.
- **Provider (`user.security.captcha.provider`)**: The CAPTCHA provider. Only `turnstile` (Cloudflare Turnstile, via the optional `com.digitalsanctuary:ds-spring-cf-turnstile` dependency) is currently supported. Defaults to `turnstile`. Supply your own `CaptchaService` bean to use a different provider; it takes precedence over the built-in one.
- **Allow Unusable Provider (`user.security.captcha.allow-unusable-provider`)**: Whether to start when the provider reports it cannot verify anything (missing Turnstile secret or site key, absent service bean). Defaults to `false` — such a provider rejects every request to every protected endpoint, so startup fails rather than shipping an outage that looks healthy. Set `true` to boot anyway and take a startup ERROR banner instead.
- **Protect Registration (`user.security.captcha.protect.registration`)**: Require CAPTCHA on `POST /user/registration`. Defaults to `true`.
- **Protect Passwordless Registration (`user.security.captcha.protect.passwordless-registration`)**: Require CAPTCHA on `POST /user/registration/passwordless`. Defaults to `true`. Only reachable if you have added that path to `user.security.unprotectedURIs` and have a WebAuthn credential service, but it creates an account and sends a verification email for an unauthenticated caller just like the standard registration endpoint.
- **Protect Reset Password (`user.security.captcha.protect.reset-password`)**: Require CAPTCHA on `POST /user/resetPassword`. Defaults to `true`.
- **Protect Resend Registration Token (`user.security.captcha.protect.resend-registration-token`)**: Require CAPTCHA on `POST /user/resendRegistrationToken`. Defaults to `true`.

**Example configuration:**
```yaml
user:
security:
captcha:
enabled: true
provider: turnstile
protect:
registration: true
reset-password: true
resend-registration-token: true
```

**Client contract**: these endpoints consume JSON bodies, so the CAPTCHA token must be sent in the `X-Captcha-Token` request header (preferred) or the `cf-turnstile-response` query parameter — it cannot be added as a form field. Rejections return `HTTP 403` with a `JSONResponse` body (`code: 8`), customizable via the `message.captcha.validation-failed` message key. The site key is exposed to MVC pages as the `captchaSiteKey` model attribute. See the README's [CAPTCHA Protection](README.md#captcha-protection-cloudflare-turnstile) section for the full client-side contract, fail-closed semantics, and scope notes (login is not covered).

### Passwordless Initial Password Step-Up (SUF-02)

`POST /user/setPassword` adds an *initial* password to a passwordless (passkey-only) account. Because there is no current credential to verify, the endpoint is gated:
Expand Down
135 changes: 135 additions & 0 deletions README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -40,6 +40,7 @@ Check out the [Spring User Framework Demo Application](https://github.com/devond
- [Account Lockout](#account-lockout)
- [Audit Logging](#audit-logging)
- [HTMX Support](#htmx-support)
- [CAPTCHA Protection (Cloudflare Turnstile)](#captcha-protection-cloudflare-turnstile)
- [User Management](#user-management)
- [Registration](#registration)
- [Profile Management](#profile-management)
Expand DownExpand Up@@ -550,6 +551,140 @@ public AuthenticationEntryPoint authenticationEntryPoint() {
}
```

### CAPTCHA Protection (Cloudflare Turnstile)

The framework can require a CAPTCHA challenge on its unauthenticated, email-sending API actions — `POST /user/registration`, `POST /user/registration/passwordless`, `POST /user/resetPassword`, and `POST /user/resendRegistrationToken` — to block automated abuse (registration spam, password-reset flooding, verification-email bombing). It is **off by default**: with `user.security.captcha.enabled=false` (the default), no CAPTCHA interceptor or provider beans are registered and no requests are checked — behavior is identical to previous releases, and no extra dependency is required.

**Setup**

Add the Turnstile library to your consuming application and configure your Cloudflare site key/secret:

```groovy
implementation 'com.digitalsanctuary:ds-spring-cf-turnstile:2.1.0'
```

```yaml
ds:
cf:
turnstile:
sitekey: <your-turnstile-site-key>
secret: <your-turnstile-secret-key>
```

Then enable CAPTCHA protection in the framework:

```yaml
user:
security:
captcha:
enabled: true
provider: turnstile
protect:
registration: true
reset-password: true
resend-registration-token: true
```

Each `protect.*` flag can be toggled independently, so you can, for example, protect registration and password reset but leave resend-verification-token open.

**Client contract**

These endpoints consume JSON request bodies, so the CAPTCHA token cannot be added as a form field — it must be sent as either:

- The `X-Captcha-Token` request header (preferred), or
- The `cf-turnstile-response` query parameter (fallback)

On failure the API returns `HTTP 403` with the same `JSONResponse` shape as other API errors:

```json
{"success":false,"redirectUrl":null,"code":8,"messages":["CAPTCHA verification failed. Please complete the challenge and try again."],"data":null}
```

The message text is customizable via the `message.captcha.validation-failed` key in your `messages.properties`.

**Widget rendering**

Consuming applications own their own templates. The framework exposes the configured site key as the `captchaSiteKey` model attribute on MVC controllers (the Turnstile library also offers `${@turnstileValidationService.getTurnstileSitekey()}` for use directly in Thymeleaf). A minimal registration page snippet:

The `data-sitekey` value must be bound by your template engine — `${captchaSiteKey}` in a plain HTML
attribute is not evaluated and Turnstile would receive the literal text. In Thymeleaf:

```html
<div class="cf-turnstile" th:attr="data-sitekey=${captchaSiteKey}" data-callback="onCaptchaSolved"></div>
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>
<script>
let captchaToken = null;
function onCaptchaSolved(token) {
captchaToken = token;
}
// when submitting the registration API call:
async function submitRegistration(registrationPayload) {
// Turnstile tokens are single-use and expire (~300s). Don't submit before the challenge
// resolves: a null token stringifies to "null", which is non-blank, so it reaches the
// provider and comes back as a generic 403 rather than a "complete the challenge" prompt.
if (!captchaToken) {
return;
}
const response = await fetch('/user/registration', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Captcha-Token': captchaToken
},
body: JSON.stringify(registrationPayload)
});
if (!response.ok) {
// The token is now spent; without a reset every retry fails as timeout-or-duplicate.
captchaToken = null;
turnstile.reset();
}
return response;
}
</script>
```

**Fail-closed semantics**

- Enabling CAPTCHA (`enabled=true`) without a resolvable provider — the Turnstile library missing from the classpath, an unrecognized `provider`, or no custom `CaptchaService` bean — fails application startup rather than silently letting requests through unprotected.
- Enabling CAPTCHA with a provider that resolves but cannot verify anything — no Turnstile secret, no site key, or an absent `TurnstileValidationService` bean — also fails startup. Such a provider rejects every registration, reset, and resend while the application looks healthy, so this is surfaced loudly rather than as a 100% rejection rate in production. Set `user.security.captcha.allow-unusable-provider=true` to start anyway and take a startup ERROR banner instead.
- If the provider is unreachable or errors at request time, the request is rejected (fail closed), not allowed through.
- Cloudflare's always-pass test keys (e.g. `1x00000000000000000000AA`) are detected at startup and logged as a prominent `WARN` banner. Never ship test keys to production.

**Scope**

- **Login is deliberately not covered.** Login is handled by a Spring Security filter, not an MVC handler, so it falls outside this interceptor-based approach; per-account lockout (`user.security.failedLoginAttempts`) already provides brute-force protection there. If you want CAPTCHA on login too, enable `ds-spring-cf-turnstile`'s own login filter with `ds.cf.turnstile.login.enabled=true`, or use Cloudflare edge-level challenges.
- A custom `CaptchaService` bean (see the `com.digitalsanctuary.spring.user.captcha.CaptchaService` SPI) fully replaces the built-in Turnstile provider if you want a different CAPTCHA vendor.
- Enforcement matches request paths with Spring's default `PathPatternParser`. If your application installs a custom parser via `PathMatchConfigurer#setPatternParser` (for example a case-insensitive one), handler mapping and CAPTCHA enforcement could disagree on exotic path spellings — don't relax path matching on an application that exposes these endpoints.

**Custom providers**

Implement `CaptchaService` and register it as a bean; it takes precedence over the built-in Turnstile provider. The SPI mirrors the `RegistrationGuard` shape used elsewhere in the framework:

```java
@Component
public class HCaptchaService implements CaptchaService {

@Override
public CaptchaVerification verify(CaptchaContext context) {
try {
return client.siteverify(context.token(), context.remoteIp())
? CaptchaVerification.verified()
: CaptchaVerification.rejected("hcaptcha reported the token invalid");
} catch (IOException e) {
// Report the failure — the framework decides that this rejects the request.
return CaptchaVerification.error("hcaptcha unreachable: " + e.getMessage());
}
}

@Override
public Optional<String> siteKey() {
return Optional.of(siteKey);
}
}
```

`CaptchaContext` carries the `CaptchaAction` being verified, so providers that bind a token to the challenge it was issued for (reCAPTCHA v3 actions) or apply per-action score thresholds can do so. The three-way `CaptchaVerification` outcome means an implementation never has to decide what a provider outage means: report `error(...)` and the framework fails closed. Throwing is safe too — the framework catches it and rejects the request with the same documented 403 body. Implementations must be thread-safe.

## User Management

### Registration
Expand Down
2 changes: 2 additions & 0 deletions build.gradle
Original file line numberDiff line numberDiff line change
Expand Up@@ -47,6 +47,7 @@ dependencies {
// Other dependencies (moved to test scope for library)
implementation 'org.passay:passay:2.0.0'
implementation 'org.apache.commons:commons-text:1.15.0'
compileOnly 'com.digitalsanctuary:ds-spring-cf-turnstile:2.1.0'
compileOnly 'jakarta.validation:jakarta.validation-api:3.1.1'
compileOnly 'org.springframework.retry:spring-retry:2.0.13'

Expand All@@ -70,6 +71,7 @@ dependencies {
testImplementation 'org.springframework.boot:spring-boot-starter-oauth2-client'
testImplementation 'org.springframework.boot:spring-boot-starter-security'
testImplementation 'org.springframework.boot:spring-boot-starter-thymeleaf'
testImplementation 'com.digitalsanctuary:ds-spring-cf-turnstile:2.1.0'
testImplementation 'org.springframework.security:spring-security-test'
testImplementation 'org.springframework.security:spring-security-webauthn'
testImplementation 'org.springframework.retry:spring-retry:2.0.13'
Expand Down
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
package com.digitalsanctuary.spring.user.captcha;

/**
* The framework API actions that can be CAPTCHA-protected.
*
* <p>
* These are the unauthenticated, email-sending endpoints an abuser can drive without an account:
* registration spam, password-reset flooding, and verification-email bombing. Each constant carries
* the context-relative path of its action, which is the single source of truth for interceptor
* registration ({@code CaptchaAutoConfiguration}), request matching
* ({@code CaptchaValidationInterceptor}), and the per-action toggles in
* {@link CaptchaConfigProperties.Protect}.
* </p>
*
* <p>
* The action is passed to {@link CaptchaService#verify(CaptchaContext)} so providers that bind a
* token to the challenge it was issued for (reCAPTCHA v3 actions) or apply per-action score
* thresholds can do so. Adding a protected action is a matter of adding a constant here and a
* toggle on {@code Protect}.
* </p>
*/
public enum CaptchaAction {

/** {@code POST /user/registration} — new account registration. */
REGISTRATION("/user/registration"),

/**
* {@code POST /user/registration/passwordless} — passkey-only account registration. Reachable
* only when a WebAuthn credential management bean is present and the consumer has added the
* path to {@code user.security.unprotectedURIs}, but when it is reachable it creates an account
* and sends a verification email for an unauthenticated caller, exactly like
* {@link #REGISTRATION} and without even requiring a password in the payload.
*/
PASSWORDLESS_REGISTRATION("/user/registration/passwordless"),

/** {@code POST /user/resetPassword} — request a password-reset email. */
RESET_PASSWORD("/user/resetPassword"),

/** {@code POST /user/resendRegistrationToken} — resend the verification email. */
RESEND_REGISTRATION_TOKEN("/user/resendRegistrationToken");

private final String path;

CaptchaAction(String path) {
this.path = path;
}

/**
* Returns the context-relative request path of this action.
*
* @return the path, e.g. {@code /user/registration}
*/
public String path() {
return path;
}
}
Loading
Loading