Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
14 commits
Select commit Hold shift + click to select a range
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 number Diff line number Diff 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 Down Expand 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 number Diff line number Diff 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 number Diff line number Diff 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 Down Expand 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 number Diff line number Diff 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 number Diff line number Diff 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