From fe75932c8fc81870ac959d6680a44dfd5e1a5ae8 Mon Sep 17 00:00:00 2001
From: Devon Hillard
Date: Sat, 15 Aug 2026 00:36:48 -0600
Subject: [PATCH 01/26] docs: add docs/TESTING.md, absorb TEST-ANALYSIS.md,
repoint @Disabled refs
---
docs/TEST-ANALYSIS.md | 74 -----------
docs/TESTING.md | 117 ++++++++++++++++++
.../spring/user/api/ApiSecurityTest.java | 2 +-
.../AuthenticatedUserApiTestSimplified.java | 2 +-
.../spring/user/api/PasswordResetApiTest.java | 2 +-
.../api/PasswordResetApiTestSimplified.java | 2 +-
.../user/api/PasswordResetCompletionTest.java | 2 +-
.../spring/user/api/UserApiTest.java | 8 +-
.../UserRegistrationComprehensiveTest.java | 2 +-
.../user/api/UserRegistrationCoreTest.java | 2 +-
.../api/UserRegistrationEdgeCaseTest.java | 2 +-
.../concurrent/AdminUserManagementTest.java | 2 +-
.../AuthenticationIntegrationTest.java | 2 +-
.../SecurityConfigurationTest.java | 2 +-
.../oauth2/GoogleOAuth2IntegrationTest.java | 2 +-
.../security/AuditLoggingIntegrationTest.java | 2 +-
.../EmailVerificationEdgeCaseTest.java | 2 +-
17 files changed, 135 insertions(+), 92 deletions(-)
delete mode 100644 docs/TEST-ANALYSIS.md
create mode 100644 docs/TESTING.md
diff --git a/docs/TEST-ANALYSIS.md b/docs/TEST-ANALYSIS.md
deleted file mode 100644
index 479c879..0000000
--- a/docs/TEST-ANALYSIS.md
+++ /dev/null
@@ -1,74 +0,0 @@
-# Test Analysis Report
-
-## Summary
-- **Total Tests**: 309
-- **Failing Tests**: 0 (all tests now pass or are disabled)
-- **Disabled Tests**: ~174 (preserved for framework improvement insights)
-- **Fixed Tests**: 16 (from original 119 failures)
-- **Created By**: Claude Code
-- **Date**: July 2025
-- **Final Status**: BUILD SUCCESSFUL - All tests pass
-
-## Key Findings
-
-### 1. Framework Architecture Mismatch
-- Tests assumed form-based authentication, but SpringUserFramework is REST API based
-- Many tests expect JSON responses but receive HTML error pages
-- Authentication mechanism differences between test expectations and actual implementation
-
-### 2. Test Categories of Failures
-
-#### Category 1: Database Cleanup Issues (FIXED)
-- Tests that delete all users/roles from database
-- **Solution**: Disabled dangerous tests, using @Transactional rollback
-
-#### Category 2: Authentication/Authorization (~40 tests)
-- Tests expect specific JSON error responses for auth failures
-- Spring Security returns empty 401/403 responses instead
-- Custom DSUserDetails not properly mocked in some tests
-
-#### Category 3: OAuth2/OIDC Tests (~20 tests)
-- Missing mock OAuth2 infrastructure
-- Tests expect OAuth2 flows that aren't configured
-
-#### Category 4: Response Format Mismatches (~25 tests)
-- Tests expect form-encoded responses but API returns JSON
-- HTML error pages returned instead of JSON errors
-- Incorrect status code expectations
-
-#### Category 5: Audit Logging (~10 tests)
-- Tests expect specific audit log formats
-- Timing issues with async audit logging
-- File-based audit logger not initialized in test environment
-
-#### Category 6: Email/Token Verification (~8 tests)
-- Mock email service not properly configured
-- Token generation/validation timing issues
-
-## Potential SpringUserFramework Improvements
-
-1. **Consistent Error Responses**: Framework should return JSON errors for REST endpoints, not HTML
-2. **Test Support**: Framework could provide test utilities for common scenarios
-3. **Documentation**: REST API endpoints and expected responses need clear documentation
-4. **Security Configuration**: Allow easier customization of Spring Security error responses
-
-## Recommendations
-
-### Short-term (For Build Success)
-1. Disable failing tests with @Disabled annotation
-2. Add descriptive messages explaining why each test is disabled
-3. Group disabled tests by category for easier future fixes
-
-### Long-term (Framework Improvements)
-1. Submit issues to SpringUserFramework for consistent JSON error responses
-2. Create test utilities for common authentication scenarios
-3. Document expected API behaviors clearly
-4. Consider creating a test starter module
-
-## Test Preservation Strategy
-
-Tests are disabled but preserved because they:
-- Reveal potential framework limitations
-- Suggest API improvements
-- Provide comprehensive test coverage goals
-- Document expected behaviors (even if currently unmet)
\ No newline at end of file
diff --git a/docs/TESTING.md b/docs/TESTING.md
new file mode 100644
index 0000000..34fc9d5
--- /dev/null
+++ b/docs/TESTING.md
@@ -0,0 +1,117 @@
+# Testing
+
+How this demo app is tested: JUnit tests, Playwright E2E tests, and the test-only API that
+supports Playwright. For the framework's own testing guide, see
+[SpringUserFramework/docs/TESTING.md](https://github.com/devondragon/SpringUserFramework/blob/main/docs/TESTING.md).
+
+## JUnit tests
+
+```bash
+./gradlew test # all tests
+./gradlew test --tests UserApiTest # one class
+./gradlew test --tests UserApiTest.resetPassword # one method
+```
+
+Tests run under the `test` Spring profile
+([`application-test.properties`](../src/test/resources/application-test.properties)), backed by
+an in-memory H2 database with a per-context unique name
+(`jdbc:h2:mem:testdb-${random.uuid}`) so each Spring context is isolated.
+
+Two more profiles support OAuth2 tests: `oauth2-mock`
+([`application-oauth2-mock.properties`](../src/test/resources/application-oauth2-mock.properties)),
+used by
+[`GoogleOAuth2IntegrationTest`](../src/test/java/com/digitalsanctuary/spring/user/oauth2/GoogleOAuth2IntegrationTest.java)
+(currently `@Disabled`); and `oauth2test`
+([`application-oauth2test.properties`](../src/test/resources/application-oauth2test.properties)),
+documented in
+[`oauth2/README.md`](../src/test/java/com/digitalsanctuary/spring/user/oauth2/README.md) for
+tests written against `OAuth2TestConfiguration` but not currently used by an active test.
+
+## Test layout
+
+- `src/test/java/com/digitalsanctuary/spring/user/...`: tests for the framework's user
+ management surface (`api/`, `concurrent/`, `config/`, `integration/`, `json/`, `oauth2/`,
+ `security/`).
+- `src/test/java/com/digitalsanctuary/spring/demo/...`: tests for the demo app's own code
+ (`event/`, `mfa/`, `registration/`, `DemoTests.java`).
+
+[`IntegrationTest`](../src/test/java/com/digitalsanctuary/spring/user/test/annotations/IntegrationTest.java)
+composes `@SpringBootTest` (against `UserDemoApplication`), `@AutoConfigureMockMvc`,
+`@AutoConfigureDataJpa`, `@ActiveProfiles("test")`, and `@Transactional` (rollback per test).
+
+Test data builders live in
+[`.../user/test/builders/`](../src/test/java/com/digitalsanctuary/spring/user/test/builders/):
+`UserTestDataBuilder`, `RoleTestDataBuilder`, `TokenTestDataBuilder`.
+
+## Disabled tests
+
+```bash
+/usr/bin/find src/test -name '*.java' | wc -l # test files
+/usr/bin/find src/test -name '*.java' | xargs grep -l @Disabled | wc -l # files with @Disabled
+```
+
+As of this writing that's 62 test files, 17 with `@Disabled`. 15 were disabled during a REST API
+alignment pass and point back to this file; they fall into these categories:
+
+- **Auth expectations**: test expects a specific JSON error body on auth failure; Spring
+ Security returns an empty 401/403, or `DSUserDetails` isn't mocked the way the test assumes.
+- **OAuth2/OIDC**: needs mock provider infrastructure not wired up for that test.
+- **Response format**: test assumes form-encoded or HTML where the endpoint returns JSON, or the
+ reverse.
+- **Audit logging**: asserts on log output with timing assumptions that don't hold under async
+ logging in the test environment.
+- **Email/token verification**: assumes mock email service or token timing not configured for
+ that test.
+- **Transaction isolation**: a user created in test setup isn't visible to the REST endpoint
+ within the same transaction.
+
+They're kept, not deleted: each documents an expected behavior or a gap worth revisiting as a
+framework improvement. The other two (`DisabledTestExample.java`,
+`AccountLockoutIntegrationTest.java`) are disabled for unrelated, self-contained reasons
+documented inline.
+
+## Playwright E2E tests
+
+Tests live in [`playwright/`](../playwright) (`@playwright/test`). Install once:
+
+```bash
+cd playwright && npm ci && npx playwright install
+```
+
+Run via npm (`playwright/package.json`: `test`, `test:chromium`, `test:headed`, `test:ui`) or the
+Gradle wrapper tasks in [`build.gradle`](../build.gradle) (`verification` group):
+`./gradlew playwrightTest` / `playwrightTestChromium` (both depend on `playwrightBrowsers` /
+`playwrightInstall`).
+
+[`playwright.config.ts`](../playwright/playwright.config.ts) starts the app itself via
+`webServer`: `./gradlew bootRun --args="--spring.profiles.active=${APP_PROFILES:-local,playwright-test}"`
+against `http://localhost:8080`, reusing an already-running server unless `CI` is set. The
+`playwright-test` profile
+([`application-playwright-test.yml`](../src/main/resources/application-playwright-test.yml))
+disables verification/reset emails (tests fetch tokens via the Test API instead), pins
+`user.security.appUrl`, and sets `allowInitialPasswordSetWithoutStepUp: true` so the passkey-only
+"set initial password" flow works without a `StepUpService` bean.
+
+The `chromium`, `firefox`, `webkit`, `Mobile Chrome`, and `Mobile Safari` projects skip specs
+tagged `@mfa-enabled` (`grepInvert`); a separate `chromium-mfa` project runs only those specs,
+against a server started with the `mfa` profile added:
+
+```bash
+APP_PROFILES=local,playwright-test,mfa npx playwright test --project=chromium-mfa
+```
+
+**Test API**:
+[`TestDataController`](../src/main/java/com/digitalsanctuary/spring/demo/test/api/TestDataController.java)
+exposes `/api/test/*` (create/enable/unlock/delete a user, fetch verification and password-reset
+tokens, health check), loaded only under `@Profile("playwright-test")`.
+[`TestApiSecurityConfig`](../src/main/java/com/digitalsanctuary/spring/demo/test/config/TestApiSecurityConfig.java)
+disables CSRF for `/api/test/**` and restricts it to requests from `127.0.0.1`,
+`0:0:0:0:0:0:0:1`, or `localhost`; everything else is denied.
+
+## CI
+
+[`.github/workflows/tests.yml`](../.github/workflows/tests.yml) runs on pull requests and pushes
+to `main`: **`unit-tests`** runs `./gradlew test` on Java 21. **`playwright-tests`** builds the
+app, starts a `mariadb:12.2` service container, installs Playwright, then runs E2E twice: once
+with `APP_PROFILES=playwright-test` against `chromium` (MFA off), once with
+`APP_PROFILES=playwright-test,mfa` against `chromium-mfa` (MFA on).
diff --git a/src/test/java/com/digitalsanctuary/spring/user/api/ApiSecurityTest.java b/src/test/java/com/digitalsanctuary/spring/user/api/ApiSecurityTest.java
index 0bdc112..781afb0 100644
--- a/src/test/java/com/digitalsanctuary/spring/user/api/ApiSecurityTest.java
+++ b/src/test/java/com/digitalsanctuary/spring/user/api/ApiSecurityTest.java
@@ -44,7 +44,7 @@
@ActiveProfiles("test")
@Transactional
@DisplayName("API Security Tests")
-@Disabled("CSRF and authentication setup issues with REST API. See docs/TEST-ANALYSIS.md")
+@Disabled("CSRF and authentication setup issues with REST API. See docs/TESTING.md")
class ApiSecurityTest {
private static final String API_BASE_PATH = "/user";
diff --git a/src/test/java/com/digitalsanctuary/spring/user/api/AuthenticatedUserApiTestSimplified.java b/src/test/java/com/digitalsanctuary/spring/user/api/AuthenticatedUserApiTestSimplified.java
index 47e8013..4cb851d 100644
--- a/src/test/java/com/digitalsanctuary/spring/user/api/AuthenticatedUserApiTestSimplified.java
+++ b/src/test/java/com/digitalsanctuary/spring/user/api/AuthenticatedUserApiTestSimplified.java
@@ -47,7 +47,7 @@
@ActiveProfiles("test")
@Transactional
@DisplayName("Authenticated User API Tests - Simplified")
-@Disabled("Authentication setup issues with DSUserDetails. See docs/TEST-ANALYSIS.md")
+@Disabled("Authentication setup issues with DSUserDetails. See docs/TESTING.md")
class AuthenticatedUserApiTestSimplified {
private static final String API_BASE_PATH = "/user";
diff --git a/src/test/java/com/digitalsanctuary/spring/user/api/PasswordResetApiTest.java b/src/test/java/com/digitalsanctuary/spring/user/api/PasswordResetApiTest.java
index af31683..24d1cbc 100644
--- a/src/test/java/com/digitalsanctuary/spring/user/api/PasswordResetApiTest.java
+++ b/src/test/java/com/digitalsanctuary/spring/user/api/PasswordResetApiTest.java
@@ -58,7 +58,7 @@
@ActiveProfiles("test")
@Transactional
@DisplayName("Password Reset API Tests")
-@Disabled("Password reset token workflow and email handling issues. See docs/TEST-ANALYSIS.md")
+@Disabled("Password reset token workflow and email handling issues. See docs/TESTING.md")
class PasswordResetApiTest {
private static final String RESET_PASSWORD_URL = "/user/resetPassword";
diff --git a/src/test/java/com/digitalsanctuary/spring/user/api/PasswordResetApiTestSimplified.java b/src/test/java/com/digitalsanctuary/spring/user/api/PasswordResetApiTestSimplified.java
index d207e46..9c63b72 100644
--- a/src/test/java/com/digitalsanctuary/spring/user/api/PasswordResetApiTestSimplified.java
+++ b/src/test/java/com/digitalsanctuary/spring/user/api/PasswordResetApiTestSimplified.java
@@ -42,7 +42,7 @@
@ActiveProfiles("test")
@Transactional
@DisplayName("Password Reset API Tests - Simplified")
-@Disabled("Validation expectations don't match API behavior. See docs/TEST-ANALYSIS.md")
+@Disabled("Validation expectations don't match API behavior. See docs/TESTING.md")
class PasswordResetApiTestSimplified {
private static final String RESET_PASSWORD_URL = "/user/resetPassword";
diff --git a/src/test/java/com/digitalsanctuary/spring/user/api/PasswordResetCompletionTest.java b/src/test/java/com/digitalsanctuary/spring/user/api/PasswordResetCompletionTest.java
index 7c82a58..564e262 100644
--- a/src/test/java/com/digitalsanctuary/spring/user/api/PasswordResetCompletionTest.java
+++ b/src/test/java/com/digitalsanctuary/spring/user/api/PasswordResetCompletionTest.java
@@ -40,7 +40,7 @@
@ActiveProfiles("test")
@Transactional
@DisplayName("Password Reset Completion Tests")
-@Disabled("Password reset completion workflow issues. See docs/TEST-ANALYSIS.md")
+@Disabled("Password reset completion workflow issues. See docs/TESTING.md")
class PasswordResetCompletionTest {
private static final String SAVE_PASSWORD_URL = "/user/savePassword";
diff --git a/src/test/java/com/digitalsanctuary/spring/user/api/UserApiTest.java b/src/test/java/com/digitalsanctuary/spring/user/api/UserApiTest.java
index 8223913..8f1b06b 100644
--- a/src/test/java/com/digitalsanctuary/spring/user/api/UserApiTest.java
+++ b/src/test/java/com/digitalsanctuary/spring/user/api/UserApiTest.java
@@ -78,7 +78,7 @@ public class UserApiTest {
@ParameterizedTest
@ArgumentsSource(ApiTestRegistrationArgumentsProvider.class)
@Order(1)
- @Disabled("Transaction isolation issue - user created in test setup not visible to REST endpoint. See docs/TEST-ANALYSIS.md")
+ @Disabled("Transaction isolation issue - user created in test setup not visible to REST endpoint. See docs/TESTING.md")
// correctly run separately
public void registerUserAccount(ApiTestArgumentsHolder argumentsHolder) throws Exception {
UserDto userDto = argumentsHolder.getUserDto();
@@ -138,7 +138,7 @@ public void resetPassword() throws Exception {
@ParameterizedTest
@ArgumentsSource(ApiTestUpdateUserArgumentsProvider.class)
@Order(3)
- @Disabled("Spring Security returns empty 401 response instead of JSON error. See docs/TEST-ANALYSIS.md")
+ @Disabled("Spring Security returns empty 401 response instead of JSON error. See docs/TESTING.md")
public void updateUser(ApiTestArgumentsHolder argumentsHolder) throws Exception {
// Ensure user exists
if (userService.findUserByEmail(argumentsHolder.getUserDto().getEmail()) == null) {
@@ -169,7 +169,7 @@ public void updateUser(ApiTestArgumentsHolder argumentsHolder) throws Exception
@ParameterizedTest
@ArgumentsSource(ApiTestUpdatePasswordArgumentsProvider.class)
@Order(4)
- @Disabled("Authentication setup issues with DSUserDetails. See docs/TEST-ANALYSIS.md")
+ @Disabled("Authentication setup issues with DSUserDetails. See docs/TESTING.md")
public void updatePassword(ApiTestArgumentsHolder argumentsHolder) throws Exception {
// Ensure user exists
if (userService.findUserByEmail(baseTestUser.getEmail()) == null) {
@@ -194,7 +194,7 @@ public void updatePassword(ApiTestArgumentsHolder argumentsHolder) throws Except
@ParameterizedTest
@ArgumentsSource(ApiTestDeleteAccountArgumentsProvider.class)
@Order(5)
- @Disabled("Authentication setup issues with DSUserDetails. See docs/TEST-ANALYSIS.md")
+ @Disabled("Authentication setup issues with DSUserDetails. See docs/TESTING.md")
public void deleteAccount(ApiTestArgumentsHolder argumentsHolder) throws Exception {
// Ensure user exists
if (userService.findUserByEmail(baseTestUser.getEmail()) == null) {
diff --git a/src/test/java/com/digitalsanctuary/spring/user/api/UserRegistrationComprehensiveTest.java b/src/test/java/com/digitalsanctuary/spring/user/api/UserRegistrationComprehensiveTest.java
index 2dd920d..071a245 100644
--- a/src/test/java/com/digitalsanctuary/spring/user/api/UserRegistrationComprehensiveTest.java
+++ b/src/test/java/com/digitalsanctuary/spring/user/api/UserRegistrationComprehensiveTest.java
@@ -48,7 +48,7 @@
@ActiveProfiles("test")
@Transactional
@DisplayName("Comprehensive User Registration API Tests")
-@Disabled("Validation error response expectations don't match API behavior. See docs/TEST-ANALYSIS.md")
+@Disabled("Validation error response expectations don't match API behavior. See docs/TESTING.md")
class UserRegistrationComprehensiveTest {
private static final String REGISTRATION_URL = "/user/registration";
diff --git a/src/test/java/com/digitalsanctuary/spring/user/api/UserRegistrationCoreTest.java b/src/test/java/com/digitalsanctuary/spring/user/api/UserRegistrationCoreTest.java
index 50df7fa..00cedca 100644
--- a/src/test/java/com/digitalsanctuary/spring/user/api/UserRegistrationCoreTest.java
+++ b/src/test/java/com/digitalsanctuary/spring/user/api/UserRegistrationCoreTest.java
@@ -39,7 +39,7 @@
@ActiveProfiles("test")
@Transactional
@DisplayName("User Registration Core Tests")
-@Disabled("Email normalization expectations don't match API behavior. See docs/TEST-ANALYSIS.md")
+@Disabled("Email normalization expectations don't match API behavior. See docs/TESTING.md")
class UserRegistrationCoreTest {
private static final String REGISTRATION_URL = "/user/registration";
diff --git a/src/test/java/com/digitalsanctuary/spring/user/api/UserRegistrationEdgeCaseTest.java b/src/test/java/com/digitalsanctuary/spring/user/api/UserRegistrationEdgeCaseTest.java
index 61d4011..f3dcbb8 100644
--- a/src/test/java/com/digitalsanctuary/spring/user/api/UserRegistrationEdgeCaseTest.java
+++ b/src/test/java/com/digitalsanctuary/spring/user/api/UserRegistrationEdgeCaseTest.java
@@ -43,7 +43,7 @@
@ActiveProfiles("test")
@Transactional
@DisplayName("User Registration Edge Case Tests")
-@Disabled("Concurrent registration and null handling expectations don't match API behavior. See docs/TEST-ANALYSIS.md")
+@Disabled("Concurrent registration and null handling expectations don't match API behavior. See docs/TESTING.md")
class UserRegistrationEdgeCaseTest {
private static final String REGISTRATION_URL = "/user/registration";
diff --git a/src/test/java/com/digitalsanctuary/spring/user/concurrent/AdminUserManagementTest.java b/src/test/java/com/digitalsanctuary/spring/user/concurrent/AdminUserManagementTest.java
index 47718ea..d212085 100644
--- a/src/test/java/com/digitalsanctuary/spring/user/concurrent/AdminUserManagementTest.java
+++ b/src/test/java/com/digitalsanctuary/spring/user/concurrent/AdminUserManagementTest.java
@@ -42,7 +42,7 @@
@ActiveProfiles("test")
@DisplayName("Admin User Management Tests")
@Transactional(propagation = Propagation.NOT_SUPPORTED)
-@Disabled("Role hierarchy and admin operations configuration issues. See docs/TEST-ANALYSIS.md")
+@Disabled("Role hierarchy and admin operations configuration issues. See docs/TESTING.md")
class AdminUserManagementTest {
@Autowired
diff --git a/src/test/java/com/digitalsanctuary/spring/user/integration/AuthenticationIntegrationTest.java b/src/test/java/com/digitalsanctuary/spring/user/integration/AuthenticationIntegrationTest.java
index 8897851..9701cb1 100644
--- a/src/test/java/com/digitalsanctuary/spring/user/integration/AuthenticationIntegrationTest.java
+++ b/src/test/java/com/digitalsanctuary/spring/user/integration/AuthenticationIntegrationTest.java
@@ -46,7 +46,7 @@
@IntegrationTest
@AutoConfigureMockMvc
@DisplayName("Authentication Integration Tests")
-@Disabled("Form-based login expectations don't match REST API architecture. See docs/TEST-ANALYSIS.md")
+@Disabled("Form-based login expectations don't match REST API architecture. See docs/TESTING.md")
class AuthenticationIntegrationTest {
@Autowired
diff --git a/src/test/java/com/digitalsanctuary/spring/user/integration/SecurityConfigurationTest.java b/src/test/java/com/digitalsanctuary/spring/user/integration/SecurityConfigurationTest.java
index 6158728..fd3cebd 100644
--- a/src/test/java/com/digitalsanctuary/spring/user/integration/SecurityConfigurationTest.java
+++ b/src/test/java/com/digitalsanctuary/spring/user/integration/SecurityConfigurationTest.java
@@ -154,7 +154,7 @@ void accessProtectedEndpoint_unauthenticated_redirectsToLogin() throws Exception
@Test
@WithMockUser(username = "security@test.com", roles = { "USER" })
@DisplayName("Should allow authenticated user to access protected endpoints")
- @Disabled("Protected endpoint /protected.html returns 404 - endpoint may not exist. See docs/TEST-ANALYSIS.md")
+ @Disabled("Protected endpoint /protected.html returns 404 - endpoint may not exist. See docs/TESTING.md")
void accessProtectedEndpoint_authenticated_allowsAccess() throws Exception {
// Test that authenticated user is properly authenticated
mockMvc.perform(get("/protected.html")).andExpect(status().isOk()).andExpect(authenticated());
diff --git a/src/test/java/com/digitalsanctuary/spring/user/oauth2/GoogleOAuth2IntegrationTest.java b/src/test/java/com/digitalsanctuary/spring/user/oauth2/GoogleOAuth2IntegrationTest.java
index cdc09ea..521225a 100644
--- a/src/test/java/com/digitalsanctuary/spring/user/oauth2/GoogleOAuth2IntegrationTest.java
+++ b/src/test/java/com/digitalsanctuary/spring/user/oauth2/GoogleOAuth2IntegrationTest.java
@@ -57,7 +57,7 @@
@ExtendWith(OAuth2MockConfiguration.WireMockExtension.class)
@Transactional
@DisplayName("Google OAuth2 Integration Tests")
-@Disabled("Requires OAuth2 mock server infrastructure. See docs/TEST-ANALYSIS.md")
+@Disabled("Requires OAuth2 mock server infrastructure. See docs/TESTING.md")
class GoogleOAuth2IntegrationTest {
@Autowired
diff --git a/src/test/java/com/digitalsanctuary/spring/user/security/AuditLoggingIntegrationTest.java b/src/test/java/com/digitalsanctuary/spring/user/security/AuditLoggingIntegrationTest.java
index b4ca35a..e828624 100644
--- a/src/test/java/com/digitalsanctuary/spring/user/security/AuditLoggingIntegrationTest.java
+++ b/src/test/java/com/digitalsanctuary/spring/user/security/AuditLoggingIntegrationTest.java
@@ -52,7 +52,7 @@
@ActiveProfiles("test")
@DisplayName("Audit Logging Integration Tests")
@Import(AuditLoggingIntegrationTest.TestConfiguration.class)
-@Disabled("Audit logger initialization and async timing issues. See docs/TEST-ANALYSIS.md")
+@Disabled("Audit logger initialization and async timing issues. See docs/TESTING.md")
class AuditLoggingIntegrationTest {
@org.springframework.boot.test.context.TestConfiguration
diff --git a/src/test/java/com/digitalsanctuary/spring/user/security/EmailVerificationEdgeCaseTest.java b/src/test/java/com/digitalsanctuary/spring/user/security/EmailVerificationEdgeCaseTest.java
index 44f413b..aba9fa7 100644
--- a/src/test/java/com/digitalsanctuary/spring/user/security/EmailVerificationEdgeCaseTest.java
+++ b/src/test/java/com/digitalsanctuary/spring/user/security/EmailVerificationEdgeCaseTest.java
@@ -67,7 +67,7 @@
@ActiveProfiles("test")
@Import(EmailVerificationEdgeCaseTest.TestClockConfiguration.class)
@DisplayName("Email Verification Edge Cases")
-@Disabled("Email verification timing issues and mock email service configuration. See docs/TEST-ANALYSIS.md")
+@Disabled("Email verification timing issues and mock email service configuration. See docs/TESTING.md")
class EmailVerificationEdgeCaseTest {
@Autowired
From ebf9afe15a7e8c77117b3ebd50259a85dbd48751 Mon Sep 17 00:00:00 2001
From: Devon Hillard
Date: Sat, 15 Aug 2026 00:38:08 -0600
Subject: [PATCH 02/26] docs: add extending guide for framework extension
points
---
docs/EXTENDING.md | 193 ++++++++++++++++++++++++++++++++++++++++++++++
1 file changed, 193 insertions(+)
create mode 100644 docs/EXTENDING.md
diff --git a/docs/EXTENDING.md b/docs/EXTENDING.md
new file mode 100644
index 0000000..3433636
--- /dev/null
+++ b/docs/EXTENDING.md
@@ -0,0 +1,193 @@
+# Extending the Spring User Framework
+
+This demo depends on `com.digitalsanctuary:ds-spring-user-framework:5.3.0` ([build.gradle](../build.gradle)). Every
+section below names one extension point the framework offers, the demo code that uses it, the configuration that wires
+it, and what you would write in your own application to do the same.
+
+Framework reference documentation lives in the library repository:
+[README.md](https://github.com/devondragon/SpringUserFramework/blob/main/README.md),
+[CONFIG.md](https://github.com/devondragon/SpringUserFramework/blob/main/CONFIG.md),
+[docs/PROFILE.md](https://github.com/devondragon/SpringUserFramework/blob/main/docs/PROFILE.md),
+[docs/REGISTRATION-GUARD.md](https://github.com/devondragon/SpringUserFramework/blob/main/docs/REGISTRATION-GUARD.md).
+
+## Custom user profile stack
+
+The framework owns the `User` entity and authentication. Application-specific user data goes in a profile entity that
+shares the user's primary key. The demo implements all five steps of
+[docs/PROFILE.md](https://github.com/devondragon/SpringUserFramework/blob/main/docs/PROFILE.md):
+
+| PROFILE.md step | Framework type | Demo class |
+| --- | --- | --- |
+| 1. Profile entity | `BaseUserProfile` | [DemoUserProfile](../src/main/java/com/digitalsanctuary/spring/demo/user/profile/DemoUserProfile.java) |
+| 2. Repository | `JpaRepository` | [DemoUserProfileRepository](../src/main/java/com/digitalsanctuary/spring/demo/user/profile/DemoUserProfileRepository.java) |
+| 3. Profile service | `UserProfileService` | [DemoUserProfileService](../src/main/java/com/digitalsanctuary/spring/demo/user/profile/DemoUserProfileService.java) |
+| 4. Session holder | `BaseSessionProfile` | [DemoSessionProfile](../src/main/java/com/digitalsanctuary/spring/demo/user/profile/session/DemoSessionProfile.java) |
+| 5. Auth listener | `BaseAuthenticationListener` | [DemoAuthenticationListener](../src/main/java/com/digitalsanctuary/spring/demo/user/profile/session/DemoAuthenticationListener.java) |
+
+`DemoUserProfile` is mapped to table `demo_user_profile` and adds `favoriteColor`, `receiveNewsletter`, and a
+`@OneToMany` list of [EventRegistration](../src/main/java/com/digitalsanctuary/spring/demo/user/profile/EventRegistration.java)
+(table `event_registrations`, with [EventRegistrationRepository](../src/main/java/com/digitalsanctuary/spring/demo/user/profile/EventRegistrationRepository.java)).
+`BaseUserProfile` supplies the `@Id`, the `@OneToOne @MapsId` link to `User`, `lastAccessed`, and `locale`, so the
+profile row's id is the user's id.
+
+`DemoUserProfileService` implements the two interface methods (`getOrCreateProfile`, `updateProfile`) and adds
+domain methods `registerForEvent(Long profileId, Long eventId)` and `unregisterFromEvent(Long profileId, Long eventId)`
+that load managed entities inside the transaction. `DemoSessionProfile` adds read helpers over the session-held
+profile (`isRegisteredForEvent`, `getFavoriteColor`) plus `refreshProfile()`, which re-reads the profile from the
+repository after a write so the session is not stale. `DemoAuthenticationListener` is a constructor-only subclass; the
+framework base class loads the profile into the session on successful authentication.
+
+In your app: create the four types with your own field set, keep the profile entity's extra columns out of the
+framework's `user_account` table, and let the base authentication listener populate the session. Note that Spring does
+not inherit `@Scope` into subclasses: annotate your `BaseSessionProfile` subclass with `@SessionScopedProfile` (or
+repeat the explicit `@Scope(SCOPE_SESSION, proxyMode = TARGET_CLASS)`), otherwise it registers as a singleton shared by
+every session. `DemoSessionProfile` currently declares only `@Component`, so it is not a model to copy on that point.
+
+## Cleaning up application data when a user is deleted
+
+The framework publishes `com.digitalsanctuary.spring.user.event.UserPreDeleteEvent` inside the deletion transaction,
+carrying `userId` and `userEmail` (not a live entity).
+[UserProfileDeletionListener](../src/main/java/com/digitalsanctuary/spring/demo/user/profile/UserProfileDeletionListener.java)
+handles it with `@EventListener` plus `@Transactional`, looks the profile up by id (same id as the user), and deletes
+it; `EventRegistration` rows go with it through `cascade = ALL, orphanRemoval = true` on the profile's collection.
+
+In your app: register one such listener per aggregate that holds a foreign key to the user, and do the work in the
+event's transaction so a failed cleanup rolls the deletion back. Whether the account is deleted or only disabled is
+controlled by `user.actuallyDeleteAccount` ([application.yml:103](../src/main/resources/application.yml)).
+
+## Building your own domain on the framework: events
+
+The Event feature is the "your application" half of the demo. It is ordinary Spring MVC plus JPA that leans on the
+framework only for identity and authorization:
+
+- [Event](../src/main/java/com/digitalsanctuary/spring/demo/event/Event.java) (table `events`) and
+ [EventRepository](../src/main/java/com/digitalsanctuary/spring/demo/event/EventRepository.java) /
+ [EventService](../src/main/java/com/digitalsanctuary/spring/demo/event/EventService.java).
+- [EventAPIController](../src/main/java/com/digitalsanctuary/spring/demo/event/EventAPIController.java): REST under
+ `/api/events`. `GET /api/events` and `GET /api/events/{id}` are open; `POST /api/events`,
+ `PUT /api/events/{id}`, `DELETE /api/events/{id}`, `POST /api/events/{eventId}/register` and
+ `POST /api/events/{eventId}/unregister` each carry a `@PreAuthorize`.
+- [EventPageController](../src/main/java/com/digitalsanctuary/spring/demo/event/EventPageController.java): the
+ Thymeleaf pages `/event/list.html`, `/event/{eventId}/details.html`, `/event/create.html`, `/event/my-events.html`.
+- [AdminController](../src/main/java/com/digitalsanctuary/spring/demo/controller/AdminController.java) gates
+ `/admin/actions.html` with `@PreAuthorize("hasAuthority('ADMIN_PRIVILEGE')")`, the same mechanism applied to a page
+ rather than an API.
+
+The authorities in those annotations are not hard-coded in Java; they come from the framework's role configuration in
+[application.yml:192-214](../src/main/resources/application.yml). `user.roles.roles-and-privileges` grants
+`CREATE_EVENT_PRIVILEGE`, `DELETE_EVENT_PRIVILEGE`, and `UPDATE_EVENT_PRIVILEGE` to `ROLE_ADMIN` (lines 200-202) and
+`REGISTER_FOR_EVENT_PRIVILEGE` to `ROLE_USER` (line 211). `user.roles.role-hierarchy` (lines 212-214) declares
+`ROLE_ADMIN > ROLE_MANAGER > ROLE_USER`, so an admin also holds the user privileges without being granted them twice.
+The framework creates the roles and privileges from this configuration at startup.
+
+In your app: define one privilege per action, list it under the roles that should have it, and use
+`hasAuthority('YOUR_PRIVILEGE')` in `@PreAuthorize` rather than checking role names. Adding a privilege is then a
+configuration change, not a code change. Property reference:
+[CONFIG.md](https://github.com/devondragon/SpringUserFramework/blob/main/CONFIG.md) and [CONFIGURATION.md](CONFIGURATION.md).
+
+## Overriding a framework service
+
+[CustomUserEmailService](../src/main/java/com/digitalsanctuary/spring/demo/service/CustomUserEmailService.java) extends
+the framework's `UserEmailService` and is annotated `@Service @Primary`, so it replaces the framework bean everywhere it
+is injected. It overrides one method, `sendForgotPasswordVerificationEmail`: when
+`app.mail.sendPasswordResetEmail` is `false` it creates and persists the reset token but sends no mail, otherwise it
+delegates to `super`. The Playwright profile sets that flag to `false`
+([application-playwright-test.yml:6-8](../src/main/resources/application-playwright-test.yml)) so E2E tests can read
+the token back through the test API instead of an inbox.
+
+In your app: subclass the framework service, add `@Primary`, keep the constructor signature (the parent takes its
+collaborators by constructor), override only the methods you need, and call `super` on the rest. The same pattern
+applies to any framework `@Service` you want to intercept, for example to route mail through a transactional email
+provider.
+
+## Web layer glue
+
+- [DemoTemplateModelAdvice](../src/main/java/com/digitalsanctuary/spring/demo/web/DemoTemplateModelAdvice.java): a
+ `@ControllerAdvice` exposing `devOrLocalProfile` as a model attribute. Templates cannot call
+ `${@environment.acceptsProfiles(...)}` in the restricted (layout-decorated) Thymeleaf expression context, so the
+ boolean is precomputed. This is the place to add any demo-only model attribute that does not come from the
+ framework's own `${userSecurity}` advice.
+- [LocaleConfiguration](../src/main/java/com/digitalsanctuary/spring/demo/util/LocaleConfiguration.java): a
+ `CookieLocaleResolver` defaulting to `Locale.US` plus a `LocaleChangeInterceptor` bound to the `lang` request
+ parameter, so `?lang=fr` switches the bundle used by the pages.
+
+In your app: use a `@ControllerAdvice` for cross-cutting view data, and add a locale resolver only if you ship more
+than one message bundle.
+
+## Registration guard
+
+[DomainRegistrationGuard](../src/main/java/com/digitalsanctuary/spring/demo/registration/DomainRegistrationGuard.java)
+implements the framework's `RegistrationGuard` SPI: `evaluate(RegistrationContext)` returns `RegistrationDecision.allow()`
+for `RegistrationSource.OAUTH2` and `OIDC`, and for form or passwordless registration allows only email addresses ending
+in `registration.guard.allowed-domain` (default `@example.com`), denying everything else with a message. The bean is
+annotated `@Profile("registration-guard")`, so it is inert until that profile is active
+(`--spring.profiles.active=local,registration-guard`). See [AUTHENTICATION.md#registration-guard](AUTHENTICATION.md#registration-guard)
+for how to run it, and
+[docs/REGISTRATION-GUARD.md](https://github.com/devondragon/SpringUserFramework/blob/main/docs/REGISTRATION-GUARD.md)
+for the full SPI contract. In your app, one `@Component` implementing the interface is the whole integration: allowlists,
+invite codes, and per-source rules all fit in `evaluate`.
+
+## Reference templates, JavaScript, and messages
+
+The framework ships the mail templates but no user-facing HTML; its README points adopters at this repository for the
+reference set. What to copy:
+
+- [templates/user/](../src/main/resources/templates/user) : `login.html`, `register.html`, `forgot-password.html`,
+ `forgot-password-change.html`, `forgot-password-pending-verification.html`, `update-user.html`,
+ `update-password.html`, `delete-account.html`, `registration-complete.html`,
+ `registration-pending-verification.html`, `request-new-verification-email.html`, and `mfa/webauthn-challenge.html`.
+ Forms post to the framework's `/user/*` endpoints and read URIs from the framework-provided `${userSecurity}` model
+ attribute rather than hard-coding paths.
+- [templates/layout.html](../src/main/resources/templates/layout.html) and
+ [templates/fragments/](../src/main/resources/templates/fragments) (`header.html`, `footer.html`): the layout dialect
+ shell, the CSRF meta tags every fetch call reads, and `sec:authorize` driven navigation.
+- [templates/mail/](../src/main/resources/templates/mail): `registration-token.html` and `forgot-password-token.html`
+ are byte-identical copies of the framework's defaults, placed at the same classpath paths so they take precedence.
+ Edit them in place to restyle the emails.
+- [static/js/user/](../src/main/resources/static/js/user), one module per page. Endpoints they call:
+ `register.js` → `POST /user/registration` and `POST /user/registration/passwordless`; `login.js` → the login form
+ action plus passkey sign-in; `forgot-password.js` → `POST /user/resetPassword`; `reset-password.js` →
+ `POST /user/savePassword`; `resend-verification.js` → `POST /user/resendRegistrationToken`; `update-user.js` →
+ `POST /user/updateUser`; `update-password.js` → `POST /user/updatePassword` and `POST /user/setPassword`;
+ `delete-account.js` → `DELETE /user/deleteAccount`; `auth-methods.js` → `GET /user/auth-methods`;
+ `webauthn-manage.js` → `GET|PUT|DELETE /user/webauthn/credentials*`, `DELETE /user/webauthn/password`, and
+ `GET /user/mfa/status`; `webauthn-register.js` and `webauthn-authenticate.js` → the Spring Security WebAuthn
+ endpoints `/webauthn/register/options`, `/webauthn/register`, `/webauthn/authenticate/options`, `/login/webauthn`;
+ `mfa-webauthn-challenge.js`, `webauthn-utils.js` are helpers with no endpoints of their own.
+- [static/js/shared.js](../src/main/resources/static/js/shared.js) (message and error rendering) and
+ [static/js/utils/password-validation.js](../src/main/resources/static/js/utils/password-validation.js) (strength
+ meter) are imported by the page modules, so copy them too.
+- [messages/messages.properties](../src/main/resources/messages/messages.properties), wired by
+ `spring.messages.basename: messages/messages` ([application.yml:71-72](../src/main/resources/application.yml)). The
+ framework appends its own bundle after yours, so redefining a framework key here (the file overrides `auth.message.*`,
+ `email.*`, and the password-policy messages) replaces the library text.
+
+## Profile-gated test-only endpoints
+
+[TestDataController](../src/main/java/com/digitalsanctuary/spring/demo/test/api/TestDataController.java) exposes
+`/api/test/**` (user lookup, create, delete, enable, unlock, verification and password-reset token retrieval, health)
+for Playwright, and is annotated `@Profile("playwright-test")` so the bean does not exist otherwise. Its delete
+endpoint publishes `UserPreDeleteEvent` itself so framework listeners clean up first.
+[TestApiSecurityConfig](../src/main/java/com/digitalsanctuary/spring/demo/test/config/TestApiSecurityConfig.java) adds
+an `@Order(1)` `SecurityFilterChain` matching `/api/test/**` that disables CSRF and permits the request only when the
+remote address is loopback, denying everything else. Both are activated by the `playwright-test` profile; see
+[TESTING.md](TESTING.md).
+
+In your app: pair the `@Profile` on the controller with a dedicated, narrow filter chain, and keep the profile out of
+production configuration.
+
+## Configuration-only extension points
+
+These need no code in the demo at all:
+
+- MFA: `user.mfa` ([application.yml:114-125](../src/main/resources/application.yml)) is off by default; the `mfa`
+ profile ([application-mfa.yml](../src/main/resources/application-mfa.yml)) turns it on with factors `PASSWORD` and
+ `WEBAUTHN` and adds the passkey registration endpoints to the unprotected list so a new user can enroll.
+- URL protection: `user.security.defaultAction: deny` plus `user.security.unprotectedURIs`
+ ([application.yml:142,152](../src/main/resources/application.yml)) decide what is public; the demo adds its own
+ `/event/**` and static paths there.
+- Remember-me: `user.security.rememberMe` ([application.yml:143-151](../src/main/resources/application.yml)) enables
+ the cookie, with the signing key read from `REMEMBER_ME_KEY` and a random per-start fallback.
+
+Every property above is documented in [CONFIGURATION.md](CONFIGURATION.md) and in the framework's
+[CONFIG.md](https://github.com/devondragon/SpringUserFramework/blob/main/CONFIG.md).
From 40949c961c8e421c4c259f721b403cfaceb21f4e Mon Sep 17 00:00:00 2001
From: Devon Hillard
Date: Sat, 15 Aug 2026 00:44:31 -0600
Subject: [PATCH 03/26] build: multi-stage Dockerfile on Java 21
Stage 1 builds the boot jar with the Gradle wrapper on eclipse-temurin:21-jdk-jammy,
so 'docker compose build' no longer needs a jar in build/libs from a prior host build.
Stage 2 is unchanged apart from the JRE 21 base. Add .dockerignore so the context stays
small and the gitignored application-local.yml can never reach an image.
---
.dockerignore | 20 ++++++++++++++++++++
Dockerfile | 24 ++++++++++++++++++++----
mise.toml | 2 +-
3 files changed, 41 insertions(+), 5 deletions(-)
create mode 100644 .dockerignore
diff --git a/.dockerignore b/.dockerignore
new file mode 100644
index 0000000..a8b2ec0
--- /dev/null
+++ b/.dockerignore
@@ -0,0 +1,20 @@
+# Keep the build context small and keep local files out of the image.
+.git/
+.github/
+.gradle/
+build/
+build.bak/
+.superpowers/
+.vscode/
+.idea/
+docs/
+playwright/
+node_modules/
+**/node_modules/
+logs/
+*.log
+
+# Gitignored local config holding real credentials: must never reach an image.
+.env
+src/main/resources/application-local.yml
+src/main/resources/application-docker-keycloak.yml
diff --git a/Dockerfile b/Dockerfile
index 4245165..11b8068 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,6 +1,22 @@
-FROM eclipse-temurin:17-jre-jammy
+# Stage 1: build the boot jar inside the image, so no local Gradle build is required.
+# JDK 21 matches the toolchain declared in build.gradle.
+FROM eclipse-temurin:21-jdk-jammy AS build
-# Install wget for healthcheck
+WORKDIR /workspace
+
+# Resolve dependencies in their own layer so editing sources does not re-download them.
+COPY gradlew gradlew
+COPY gradle gradle
+COPY build.gradle settings.gradle ./
+RUN ./gradlew --no-daemon dependencies --configuration runtimeClasspath > /dev/null
+
+COPY src src
+RUN ./gradlew --no-daemon bootJar -x test
+
+# Stage 2: runtime image, JRE only.
+FROM eclipse-temurin:21-jre-jammy
+
+# Install wget for the healthcheck (the JRE image has no curl)
RUN apt-get update && apt-get install -y wget && rm -rf /var/lib/apt/lists/*
# Add a non-root user to run the application
@@ -9,8 +25,8 @@ RUN groupadd -r spring && useradd -r -g spring spring
# Set working directory
WORKDIR /opt/app
-# Copy the JAR file
-COPY build/libs/*SNAPSHOT.jar app.jar
+# Copy the JAR file built in stage 1
+COPY --from=build /workspace/build/libs/*-SNAPSHOT.jar app.jar
# Set ownership of the files
RUN chown -R spring:spring /opt/app
diff --git a/mise.toml b/mise.toml
index 18090eb..8931355 100644
--- a/mise.toml
+++ b/mise.toml
@@ -1,2 +1,2 @@
[tools]
-java = "17"
+java = "21"
From 26edbf49870693ecedbf0392742749508cc92158 Mon Sep 17 00:00:00 2001
From: Devon Hillard
Date: Sat, 15 Aug 2026 00:44:31 -0600
Subject: [PATCH 04/26] build: keep developmentOnly dependencies out of the
packaged jar
runtimeOnly extended developmentOnly, which put spring-boot-docker-compose into the boot
jar. The packaged app then ran the Docker Compose lifecycle, and with a compose file
configured it failed at startup with "'files' content [compose.dev.yaml] must exist".
bootRun still gets the configuration on its classpath explicitly.
---
build.gradle | 6 +++---
1 file changed, 3 insertions(+), 3 deletions(-)
diff --git a/build.gradle b/build.gradle
index e5b3044..76ef97a 100644
--- a/build.gradle
+++ b/build.gradle
@@ -17,10 +17,10 @@ java {
// Define the configurations used in the project
configurations {
+ // Keep developmentOnly out of runtimeOnly, and so out of the boot jar. spring-boot-docker-compose
+ // is declared developmentOnly precisely so a packaged jar never tries to start Docker Compose;
+ // extending runtimeOnly from it put it back in the jar. bootRun adds it to the classpath below.
developmentOnly
- runtimeOnly {
- extendsFrom developmentOnly
- }
testImplementation {
extendsFrom runtimeOnly
}
From 1821513ff9889be4e03322d16187116d489ca4f7 Mon Sep 17 00:00:00 2001
From: Devon Hillard
Date: Sat, 15 Aug 2026 00:44:31 -0600
Subject: [PATCH 05/26] fix: make the documented run paths work from a fresh
clone
- application.yml: default spring.docker.compose.file to compose.dev.yaml, so bootRun
starts a matching MariaDB instead of the full compose.yaml app stack. Previously this
lived only in the gitignored application-local.yml.
- application.yml: add /actuator/health to unprotectedURIs, so the Dockerfile and compose
healthchecks can reach it. Other actuator endpoints stay behind login.
- application-local.yml-example: replace the leftover mymariadb/mydb datasource values
with the compose.dev.yaml ones, and add the sample-event seed block (data-local.sql).
- compose.yaml: skip verification email in the demo stack (the mail container cannot
reach real inboxes) and align the app healthcheck with the keycloak stack.
- docker-compose-keycloak.yml: drop the obsolete version key, and use wget for the app
healthcheck since the JRE image has no curl.
---
compose.yaml | 7 ++++++-
docker-compose-keycloak.yml | 4 +---
.../resources/application-local.yml-example | 19 +++++++++++++------
src/main/resources/application.yml | 10 +++++++++-
4 files changed, 29 insertions(+), 11 deletions(-)
diff --git a/compose.yaml b/compose.yaml
index 8f9ae7b..bfe2023 100644
--- a/compose.yaml
+++ b/compose.yaml
@@ -78,8 +78,13 @@ services:
SPRING_MAIL_PROPERTIES_MAIL_SMTP_AUTH: "false"
SPRING_MAIL_PROPERTIES_MAIL_SMTP_STARTTLS_ENABLE: "false"
SPRING_MAIL_PROPERTIES_MAIL_SMTP_STARTTLS_REQUIRED: "false"
+ # The mailserver container is a relay with no route to real inboxes, so a verification link
+ # would never arrive. With this false the framework enables new accounts immediately and you
+ # can log in straight after registering. To exercise verification instead, set this to true
+ # and point the SPRING_MAIL_* values above at a real SMTP server.
+ USER_REGISTRATION_SENDVERIFICATIONEMAIL: "false"
healthcheck:
- test: ["CMD", "wget", "-qO-", "http://localhost:8080/actuator/health"]
+ test: ["CMD", "wget", "-q", "--spider", "http://localhost:8080/actuator/health"]
interval: 30s
timeout: 10s
retries: 5
diff --git a/docker-compose-keycloak.yml b/docker-compose-keycloak.yml
index b2ee2bb..ddf6aa3 100644
--- a/docker-compose-keycloak.yml
+++ b/docker-compose-keycloak.yml
@@ -1,5 +1,3 @@
-version: "3.8"
-
services:
myapp-db:
image: mariadb:12.2
@@ -81,7 +79,7 @@ services:
SPRING_MAIL_PROPERTIES_MAIL_SMTP_STARTTLS_ENABLE: "false"
SPRING_MAIL_PROPERTIES_MAIL_SMTP_STARTTLS_REQUIRED: "false"
healthcheck:
- test: ["CMD", "curl", "-f", "http://localhost:8080/actuator/health"]
+ test: ["CMD", "wget", "-q", "--spider", "http://localhost:8080/actuator/health"]
interval: 30s
timeout: 10s
retries: 5
diff --git a/src/main/resources/application-local.yml-example b/src/main/resources/application-local.yml-example
index 171d65b..6da89d2 100644
--- a/src/main/resources/application-local.yml-example
+++ b/src/main/resources/application-local.yml-example
@@ -11,16 +11,23 @@ logging:
security: DEBUG # Set logging level for security
spring:
- docker:
- compose:
- file: compose.dev.yaml
application:
name: Spring User Framework Demo App # Change this as per your convenience
+ # These match the MariaDB service in compose.dev.yaml, which bootRun starts by default (see
+ # spring.docker.compose in application.yml). While that integration is active the URL, username and
+ # password below are overridden by the service connection it derives from the running container;
+ # they apply when you set spring.docker.compose.enabled to false and run your own database.
datasource:
driverClassName: org.mariadb.jdbc.Driver # If you use mariadb database
- password: mydatabaseuserpassword
- url: jdbc:mariadb://mymariadb:3306/mydb?createDatabaseIfNotExist=true
- username: mydatabaseuser
+ password: springuser
+ url: jdbc:mariadb://localhost:3306/springuser?createDatabaseIfNotExist=true
+ username: springuser
+ sql:
+ init:
+ mode: always # Run the schema/data scripts on every start
+ platform: local # Loads src/main/resources/data-local.sql (sample events, INSERT IGNORE so reruns are safe)
+ jpa:
+ defer-datasource-initialization: true # Let Hibernate create the tables before data-local.sql runs
mail: # Mail configuration
username: AAAAAAAAA # Mail server username
password: BBBBBBBBBBB # Mail server password
diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml
index a388087..29e166b 100644
--- a/src/main/resources/application.yml
+++ b/src/main/resources/application.yml
@@ -63,6 +63,14 @@ spring:
show-sql: "false" # Enable or disable SQL logging
application: # Application configuration
name: User Framework Demo # Application name
+ docker:
+ compose:
+ # `./gradlew bootRun` starts this file's services (a MariaDB matching the datasource below) via
+ # spring-boot-docker-compose, and stops them when the app stops. That dependency is developmentOnly,
+ # so it is on the bootRun classpath only: it is absent from the packaged jar and from the test
+ # classpath, where this setting is therefore inert. Set enabled: false to use your own database.
+ file: compose.dev.yaml
+ # enabled: false
datasource: # Datasource configuration
password: springuser # Database password
url: jdbc:mariadb://localhost:3306/springuser?createDatabaseIfNotExist=true # Database URL
@@ -149,7 +157,7 @@ user:
key: ${REMEMBER_ME_KEY:${random.uuid}}
# tokenValiditySeconds: 1209600 # How long a remember-me token stays valid. Default is 14 days.
# usePersistentTokens: true # Store tokens in the persistent_logins table (see framework db-scripts) so they can be revoked server-side.
- unprotectedURIs: /,/index.html,/favicon.ico,/apple-touch-icon-precomposed.png,/css/*,/js/*,/js/user/*,/js/event/*,/js/utils/*,/img/**,/user/registration,/user/registration/passwordless,/user/resendRegistrationToken,/user/resetPassword,/user/registrationConfirm,/user/changePassword,/user/savePassword,/oauth2/authorization/*,/login,/user/login,/user/login.html,/swagger-ui.html,/swagger-ui/**,/v3/api-docs/**,/event/,/event/list.html,/event/**,/about.html,/error,/error.html,/webauthn/authenticate/**,/login/webauthn # A comma delimited list of URIs that should not be protected by Spring Security if the defaultAction is deny.
+ unprotectedURIs: /,/index.html,/favicon.ico,/apple-touch-icon-precomposed.png,/css/*,/js/*,/js/user/*,/js/event/*,/js/utils/*,/img/**,/user/registration,/user/registration/passwordless,/user/resendRegistrationToken,/user/resetPassword,/user/registrationConfirm,/user/changePassword,/user/savePassword,/oauth2/authorization/*,/login,/user/login,/user/login.html,/swagger-ui.html,/swagger-ui/**,/v3/api-docs/**,/event/,/event/list.html,/event/**,/about.html,/error,/error.html,/webauthn/authenticate/**,/login/webauthn,/actuator/health # A comma delimited list of URIs that should not be protected by Spring Security if the defaultAction is deny.
protectedURIs: /protected.html # A comma delimited list of URIs that should be protected by Spring Security if the defaultAction is allow.
disableCSRFdURIs: /no-csrf-test # A comma delimited list of URIs that should not be protected by CSRF protection. This may include API endpoints that need to be called without a CSRF token.
From c08df71ad3200d00d43fc9e186c7617878038d37 Mon Sep 17 00:00:00 2001
From: Devon Hillard
Date: Sat, 15 Aug 2026 00:44:31 -0600
Subject: [PATCH 06/26] chore: remove TempTest startup debug logger
Leftover debug code: on the local and dev profiles it logged every DemoUserProfile row
at startup. Nothing references it.
---
.../spring/demo/util/TempTest.java | 30 -------------------
1 file changed, 30 deletions(-)
delete mode 100644 src/main/java/com/digitalsanctuary/spring/demo/util/TempTest.java
diff --git a/src/main/java/com/digitalsanctuary/spring/demo/util/TempTest.java b/src/main/java/com/digitalsanctuary/spring/demo/util/TempTest.java
deleted file mode 100644
index 7357b62..0000000
--- a/src/main/java/com/digitalsanctuary/spring/demo/util/TempTest.java
+++ /dev/null
@@ -1,30 +0,0 @@
-package com.digitalsanctuary.spring.demo.util;
-
-import org.springframework.boot.context.event.ApplicationStartedEvent;
-import org.springframework.context.annotation.Profile;
-import org.springframework.context.event.EventListener;
-import org.springframework.stereotype.Component;
-import com.digitalsanctuary.spring.demo.user.profile.DemoUserProfileRepository;
-import jakarta.transaction.Transactional;
-import lombok.RequiredArgsConstructor;
-import lombok.extern.slf4j.Slf4j;
-
-/**
- * This is a class that is used to test the library's functionality outside of the JUnit test context.
- */
-@Slf4j
-@Component
-@RequiredArgsConstructor
-@Profile({"local", "dev"})
-public class TempTest {
-
- private final DemoUserProfileRepository demoUserProfileRepository;
-
- @Transactional
- @EventListener(ApplicationStartedEvent.class)
- public void test() {
- log.info("This is a test");
- log.info("{}", demoUserProfileRepository.findAll());
- }
-
-}
From 7269f026c58d5de43d3b73698540b652af9f1a5a Mon Sep 17 00:00:00 2001
From: Devon Hillard
Date: Sat, 15 Aug 2026 00:49:06 -0600
Subject: [PATCH 07/26] docs: correct event api auth, form action, and mfa
claims in extending guide
---
docs/EXTENDING.md | 64 +++++++++++++++++++++++++++++++----------------
1 file changed, 42 insertions(+), 22 deletions(-)
diff --git a/docs/EXTENDING.md b/docs/EXTENDING.md
index 3433636..c332fad 100644
--- a/docs/EXTENDING.md
+++ b/docs/EXTENDING.md
@@ -37,11 +37,11 @@ profile (`isRegisteredForEvent`, `getFavoriteColor`) plus `refreshProfile()`, wh
repository after a write so the session is not stale. `DemoAuthenticationListener` is a constructor-only subclass; the
framework base class loads the profile into the session on successful authentication.
-In your app: create the four types with your own field set, keep the profile entity's extra columns out of the
+In your app: create the five types with your own field set, keep the profile entity's extra columns out of the
framework's `user_account` table, and let the base authentication listener populate the session. Note that Spring does
not inherit `@Scope` into subclasses: annotate your `BaseSessionProfile` subclass with `@SessionScopedProfile` (or
-repeat the explicit `@Scope(SCOPE_SESSION, proxyMode = TARGET_CLASS)`), otherwise it registers as a singleton shared by
-every session. `DemoSessionProfile` currently declares only `@Component`, so it is not a model to copy on that point.
+repeat the explicit `@Scope(value = WebApplicationContext.SCOPE_SESSION, proxyMode = ScopedProxyMode.TARGET_CLASS)`),
+otherwise it registers as a singleton shared by every HTTP session.
## Cleaning up application data when a user is deleted
@@ -64,9 +64,11 @@ framework only for identity and authorization:
[EventRepository](../src/main/java/com/digitalsanctuary/spring/demo/event/EventRepository.java) /
[EventService](../src/main/java/com/digitalsanctuary/spring/demo/event/EventService.java).
- [EventAPIController](../src/main/java/com/digitalsanctuary/spring/demo/event/EventAPIController.java): REST under
- `/api/events`. `GET /api/events` and `GET /api/events/{id}` are open; `POST /api/events`,
- `PUT /api/events/{id}`, `DELETE /api/events/{id}`, `POST /api/events/{eventId}/register` and
- `POST /api/events/{eventId}/unregister` each carry a `@PreAuthorize`.
+ `/api/events`. `POST /api/events`, `PUT /api/events/{id}`, `DELETE /api/events/{id}`,
+ `POST /api/events/{eventId}/register` and `POST /api/events/{eventId}/unregister` each carry a `@PreAuthorize`.
+ `GET /api/events` and `GET /api/events/{id}` carry no method-level authorization, but URL-level security still
+ requires an authenticated user because `/api/events` is not listed in `unprotectedURIs`. The two layers are
+ independent: method annotations refine what URL rules already allow through.
- [EventPageController](../src/main/java/com/digitalsanctuary/spring/demo/event/EventPageController.java): the
Thymeleaf pages `/event/list.html`, `/event/{eventId}/details.html`, `/event/create.html`, `/event/my-events.html`.
- [AdminController](../src/main/java/com/digitalsanctuary/spring/demo/controller/AdminController.java) gates
@@ -109,7 +111,8 @@ provider.
framework's own `${userSecurity}` advice.
- [LocaleConfiguration](../src/main/java/com/digitalsanctuary/spring/demo/util/LocaleConfiguration.java): a
`CookieLocaleResolver` defaulting to `Locale.US` plus a `LocaleChangeInterceptor` bound to the `lang` request
- parameter, so `?lang=fr` switches the bundle used by the pages.
+ parameter, so `?lang=fr` sets the session locale in a cookie. The demo ships one bundle only, so nothing visible
+ changes today; the wiring is there for when localized bundles are added.
In your app: use a `@ControllerAdvice` for cross-cutting view data, and add a locale resolver only if you ship more
than one message bundle.
@@ -136,24 +139,39 @@ reference set. What to copy:
`forgot-password-change.html`, `forgot-password-pending-verification.html`, `update-user.html`,
`update-password.html`, `delete-account.html`, `registration-complete.html`,
`registration-pending-verification.html`, `request-new-verification-email.html`, and `mfa/webauthn-challenge.html`.
- Forms post to the framework's `/user/*` endpoints and read URIs from the framework-provided `${userSecurity}` model
- attribute rather than hard-coding paths.
+ The forms post to the fixed `/user/*` API paths (`login.html` is the exception: its action comes from
+ `${userSecurity.loginActionUri}`, since the login processing URL is configurable). The framework-provided
+ `${userSecurity}` model attribute supplies the configurable page URIs used in navigation, for example
+ `fragments/header.html` and `index.html` link to `${userSecurity.loginPageUri}` and `${userSecurity.registrationUri}`.
+ Page templates need a controller mapping: the framework serves its own known pages, but the demo maps
+ `/user/mfa/webauthn-challenge.html` itself in
+ [PageController](../src/main/java/com/digitalsanctuary/spring/demo/controller/PageController.java), because that path
+ is the `user.mfa.webauthnEntryPointUri` value at
+ [application.yml:125](../src/main/resources/application.yml). Copying `templates/user/mfa/` means copying that
+ mapping too.
- [templates/layout.html](../src/main/resources/templates/layout.html) and
[templates/fragments/](../src/main/resources/templates/fragments) (`header.html`, `footer.html`): the layout dialect
shell, the CSRF meta tags every fetch call reads, and `sec:authorize` driven navigation.
- [templates/mail/](../src/main/resources/templates/mail): `registration-token.html` and `forgot-password-token.html`
are byte-identical copies of the framework's defaults, placed at the same classpath paths so they take precedence.
Edit them in place to restyle the emails.
-- [static/js/user/](../src/main/resources/static/js/user), one module per page. Endpoints they call:
- `register.js` → `POST /user/registration` and `POST /user/registration/passwordless`; `login.js` → the login form
- action plus passkey sign-in; `forgot-password.js` → `POST /user/resetPassword`; `reset-password.js` →
- `POST /user/savePassword`; `resend-verification.js` → `POST /user/resendRegistrationToken`; `update-user.js` →
- `POST /user/updateUser`; `update-password.js` → `POST /user/updatePassword` and `POST /user/setPassword`;
- `delete-account.js` → `DELETE /user/deleteAccount`; `auth-methods.js` → `GET /user/auth-methods`;
- `webauthn-manage.js` → `GET|PUT|DELETE /user/webauthn/credentials*`, `DELETE /user/webauthn/password`, and
- `GET /user/mfa/status`; `webauthn-register.js` and `webauthn-authenticate.js` → the Spring Security WebAuthn
- endpoints `/webauthn/register/options`, `/webauthn/register`, `/webauthn/authenticate/options`, `/login/webauthn`;
- `mfa-webauthn-challenge.js`, `webauthn-utils.js` are helpers with no endpoints of their own.
+- [static/js/user/](../src/main/resources/static/js/user), one module per page, calling these endpoints:
+
+ | Module | Endpoints |
+ | --- | --- |
+ | `register.js` | `POST /user/registration`, `POST /user/registration/passwordless` |
+ | `login.js` | the login form action, plus passkey sign-in via `webauthn-authenticate.js` |
+ | `forgot-password.js` | `POST /user/resetPassword` |
+ | `reset-password.js` | `POST /user/savePassword` |
+ | `resend-verification.js` | `POST /user/resendRegistrationToken` |
+ | `update-user.js` | `POST /user/updateUser` |
+ | `update-password.js` | `POST /user/updatePassword`, `POST /user/setPassword` |
+ | `delete-account.js` | `DELETE /user/deleteAccount` |
+ | `auth-methods.js` | `GET /user/auth-methods` |
+ | `webauthn-manage.js` | `GET /user/webauthn/credentials`, `PUT /user/webauthn/credentials/{id}/label`, `DELETE /user/webauthn/credentials/{id}`, `DELETE /user/webauthn/password`, `GET /user/mfa/status` |
+ | `webauthn-register.js`, `webauthn-authenticate.js` | the Spring Security WebAuthn endpoints `/webauthn/register/options`, `/webauthn/register`, `/webauthn/authenticate/options`, `/login/webauthn` |
+ | `mfa-webauthn-challenge.js`, `webauthn-utils.js` | none of their own; they delegate to the modules above |
+
- [static/js/shared.js](../src/main/resources/static/js/shared.js) (message and error rendering) and
[static/js/utils/password-validation.js](../src/main/resources/static/js/utils/password-validation.js) (strength
meter) are imported by the page modules, so copy them too.
@@ -180,9 +198,11 @@ production configuration.
These need no code in the demo at all:
-- MFA: `user.mfa` ([application.yml:114-125](../src/main/resources/application.yml)) is off by default; the `mfa`
- profile ([application-mfa.yml](../src/main/resources/application-mfa.yml)) turns it on with factors `PASSWORD` and
- `WEBAUTHN` and adds the passkey registration endpoints to the unprotected list so a new user can enroll.
+- MFA: `user.mfa` ([application.yml:114-125](../src/main/resources/application.yml)) declares the factors `PASSWORD`
+ and `WEBAUTHN` (lines 119-121) and the entry-point URIs, but is disabled at line 118. The `mfa` profile
+ ([application-mfa.yml](../src/main/resources/application-mfa.yml)) only flips `enabled: true`, allows the initial
+ password-set flow without a `StepUpService`, and adds the passkey registration endpoints to the unprotected list so a
+ new user can enroll.
- URL protection: `user.security.defaultAction: deny` plus `user.security.unprotectedURIs`
([application.yml:142,152](../src/main/resources/application.yml)) decide what is public; the demo adds its own
`/event/**` and static paths there.
From 019b4228c47b51f6ad1da359710b68906c1af3ba Mon Sep 17 00:00:00 2001
From: Devon Hillard
Date: Sat, 15 Aug 2026 00:53:18 -0600
Subject: [PATCH 08/26] fix: session-scope DemoSessionProfile
DemoSessionProfile was annotated only @Component. Spring's @Scope is not
inherited from BaseSessionProfile, so the bean was a singleton shared by
every HTTP session and one user's profile leaked to all other users. Use
the framework's @SessionScopedProfile meta-annotation instead, and add a
context test that asserts the scopedTarget bean definition has session
scope and that the injected reference is a scoped proxy.
Also repoints the DomainRegistrationGuard Javadoc link at the framework's
docs/REGISTRATION-GUARD.md, which moved out of the repository root.
---
.../registration/DomainRegistrationGuard.java | 2 +-
.../profile/session/DemoSessionProfile.java | 11 +++-
.../session/DemoSessionProfileScopeTest.java | 52 +++++++++++++++++++
3 files changed, 62 insertions(+), 3 deletions(-)
create mode 100644 src/test/java/com/digitalsanctuary/spring/demo/user/profile/session/DemoSessionProfileScopeTest.java
diff --git a/src/main/java/com/digitalsanctuary/spring/demo/registration/DomainRegistrationGuard.java b/src/main/java/com/digitalsanctuary/spring/demo/registration/DomainRegistrationGuard.java
index 9d8b6dc..116617d 100644
--- a/src/main/java/com/digitalsanctuary/spring/demo/registration/DomainRegistrationGuard.java
+++ b/src/main/java/com/digitalsanctuary/spring/demo/registration/DomainRegistrationGuard.java
@@ -28,7 +28,7 @@
* property (defaults to {@code @example.com}).
*
* See the
- *
+ *
* Registration Guard documentation for the full SPI reference.
*
* @see RegistrationGuard
diff --git a/src/main/java/com/digitalsanctuary/spring/demo/user/profile/session/DemoSessionProfile.java b/src/main/java/com/digitalsanctuary/spring/demo/user/profile/session/DemoSessionProfile.java
index 49f8c6c..2632633 100644
--- a/src/main/java/com/digitalsanctuary/spring/demo/user/profile/session/DemoSessionProfile.java
+++ b/src/main/java/com/digitalsanctuary/spring/demo/user/profile/session/DemoSessionProfile.java
@@ -1,13 +1,20 @@
package com.digitalsanctuary.spring.demo.user.profile.session;
import org.springframework.beans.factory.annotation.Autowired;
-import org.springframework.stereotype.Component;
import com.digitalsanctuary.spring.demo.event.Event;
import com.digitalsanctuary.spring.demo.user.profile.DemoUserProfile;
import com.digitalsanctuary.spring.demo.user.profile.DemoUserProfileRepository;
import com.digitalsanctuary.spring.user.profile.session.BaseSessionProfile;
+import com.digitalsanctuary.spring.user.profile.session.SessionScopedProfile;
-@Component
+/**
+ * Session-scoped profile for the demo user.
+ *
+ * Annotated with {@link SessionScopedProfile} rather than plain {@code @Component}. Spring's {@code @Scope} is
+ * not inherited from {@link BaseSessionProfile}, so a plain {@code @Component} here would make this a singleton
+ * shared by every HTTP session and leak one user's profile to all other users.
+ */
+@SessionScopedProfile
public class DemoSessionProfile extends BaseSessionProfile {
@Autowired
diff --git a/src/test/java/com/digitalsanctuary/spring/demo/user/profile/session/DemoSessionProfileScopeTest.java b/src/test/java/com/digitalsanctuary/spring/demo/user/profile/session/DemoSessionProfileScopeTest.java
new file mode 100644
index 0000000..6da04d2
--- /dev/null
+++ b/src/test/java/com/digitalsanctuary/spring/demo/user/profile/session/DemoSessionProfileScopeTest.java
@@ -0,0 +1,52 @@
+package com.digitalsanctuary.spring.demo.user.profile.session;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.springframework.aop.support.AopUtils;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.beans.factory.config.BeanDefinition;
+import org.springframework.beans.factory.config.ConfigurableListableBeanFactory;
+import org.springframework.context.ApplicationContext;
+import org.springframework.context.ConfigurableApplicationContext;
+import org.springframework.web.context.WebApplicationContext;
+import com.digitalsanctuary.spring.user.test.annotations.IntegrationTest;
+
+/**
+ * Guards against the session profile silently becoming a singleton.
+ *
+ * Spring's {@code @Scope} is not inherited, so a subclass of {@code BaseSessionProfile} annotated only with
+ * {@code @Component} would be one instance shared by every HTTP session, leaking one user's profile to all
+ * other users.
+ */
+@IntegrationTest
+@DisplayName("DemoSessionProfile Scope Tests")
+class DemoSessionProfileScopeTest {
+
+ /** Bean name of the real instance behind the scoped proxy. */
+ private static final String SCOPED_TARGET_BEAN_NAME = "scopedTarget.demoSessionProfile";
+
+ @Autowired
+ private ApplicationContext applicationContext;
+
+ @Autowired
+ private DemoSessionProfile demoSessionProfile;
+
+ @Test
+ @DisplayName("Bean definition is session scoped, not singleton")
+ void beanDefinitionIsSessionScoped() {
+ ConfigurableListableBeanFactory beanFactory = ((ConfigurableApplicationContext) applicationContext).getBeanFactory();
+
+ assertThat(beanFactory.containsBeanDefinition(SCOPED_TARGET_BEAN_NAME))
+ .as("DemoSessionProfile must be registered behind a scoped proxy (bean '%s')", SCOPED_TARGET_BEAN_NAME).isTrue();
+
+ BeanDefinition targetDefinition = beanFactory.getBeanDefinition(SCOPED_TARGET_BEAN_NAME);
+ assertThat(targetDefinition.getScope()).isEqualTo(WebApplicationContext.SCOPE_SESSION);
+ }
+
+ @Test
+ @DisplayName("Injected reference is a scoped proxy, not the target instance")
+ void injectedReferenceIsAScopedProxy() {
+ assertThat(AopUtils.isAopProxy(demoSessionProfile)).as("injected DemoSessionProfile must be a scoped proxy").isTrue();
+ }
+}
From 19111445b7cdaa8ba24ca2883b7bc8a6f6456ef8 Mon Sep 17 00:00:00 2001
From: Devon Hillard
Date: Sat, 15 Aug 2026 00:56:14 -0600
Subject: [PATCH 09/26] feat: admin lock/unlock account endpoints
The admin actions page posted {email} to /admin/lockAccount and
/admin/unlockAccount, but no handler existed in the demo or the framework,
so both actions always failed. Add AdminAPIController with both POST
endpoints, guarded by ADMIN_PRIVILEGE like the page itself. Lock sets
locked and lockedDate; unlock clears both and resets the failed login
counter, matching what LoginAttemptService does when a lockout expires.
Every outcome returns a JSONResponse body so the page's fetch() can read
messages[0].
---
.../demo/controller/AdminAPIController.java | 96 ++++++++++++
.../controller/AdminAPIControllerTest.java | 143 ++++++++++++++++++
2 files changed, 239 insertions(+)
create mode 100644 src/main/java/com/digitalsanctuary/spring/demo/controller/AdminAPIController.java
create mode 100644 src/test/java/com/digitalsanctuary/spring/demo/controller/AdminAPIControllerTest.java
diff --git a/src/main/java/com/digitalsanctuary/spring/demo/controller/AdminAPIController.java b/src/main/java/com/digitalsanctuary/spring/demo/controller/AdminAPIController.java
new file mode 100644
index 0000000..3a8e60b
--- /dev/null
+++ b/src/main/java/com/digitalsanctuary/spring/demo/controller/AdminAPIController.java
@@ -0,0 +1,96 @@
+package com.digitalsanctuary.spring.demo.controller;
+
+import java.util.Date;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.ResponseEntity;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.transaction.annotation.Transactional;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+import com.digitalsanctuary.spring.user.persistence.model.User;
+import com.digitalsanctuary.spring.user.persistence.repository.UserRepository;
+import com.digitalsanctuary.spring.user.util.JSONResponse;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+
+/**
+ * JSON endpoints behind the admin actions page (templates/admin/actions.html and
+ * static/js/admin/admin-action.js). All endpoints require ADMIN_PRIVILEGE, the same authority as the page.
+ *
+ * Every outcome returns a {@link JSONResponse} body so the page's fetch() can always read messages[0].
+ */
+@Slf4j
+@RestController
+@RequiredArgsConstructor
+@RequestMapping("/admin")
+public class AdminAPIController {
+
+ private final UserRepository userRepository;
+
+ /**
+ * Request body for the lock and unlock endpoints.
+ *
+ * @param email the email address of the account to act on
+ */
+ public record AccountActionRequest(String email) {
+ }
+
+ /**
+ * Locks a user account. A locked user fails authentication until the lockout duration elapses or an admin
+ * unlocks the account.
+ *
+ * @param request the account to lock
+ * @return 200 on success, 400 when the email is missing, 404 when no user has that email
+ */
+ @PostMapping("/lockAccount")
+ @PreAuthorize("hasAuthority('ADMIN_PRIVILEGE')")
+ @Transactional
+ public ResponseEntity lockAccount(@RequestBody AccountActionRequest request) {
+ return setLocked(request, true);
+ }
+
+ /**
+ * Unlocks a user account and clears its failed login counter, matching what the framework's
+ * LoginAttemptService does when a lockout expires.
+ *
+ * @param request the account to unlock
+ * @return 200 on success, 400 when the email is missing, 404 when no user has that email
+ */
+ @PostMapping("/unlockAccount")
+ @PreAuthorize("hasAuthority('ADMIN_PRIVILEGE')")
+ @Transactional
+ public ResponseEntity unlockAccount(@RequestBody AccountActionRequest request) {
+ return setLocked(request, false);
+ }
+
+ private ResponseEntity setLocked(AccountActionRequest request, boolean locked) {
+ String email = request.email() != null ? request.email().trim() : "";
+ if (email.isEmpty()) {
+ return response(HttpStatus.BAD_REQUEST, false, "Email is required.");
+ }
+
+ User user = userRepository.findByEmail(email);
+ if (user == null) {
+ log.info("Admin lock/unlock requested for unknown email: {}", email);
+ return response(HttpStatus.NOT_FOUND, false, "User not found.");
+ }
+
+ user.setLocked(locked);
+ if (locked) {
+ user.setLockedDate(new Date());
+ } else {
+ user.setLockedDate(null);
+ user.setFailedLoginAttempts(0);
+ }
+ userRepository.save(user);
+ log.info("Admin set locked={} for user: {}", locked, email);
+
+ return response(HttpStatus.OK, true, locked ? "Account locked." : "Account unlocked.");
+ }
+
+ private ResponseEntity response(HttpStatus status, boolean success, String message) {
+ return ResponseEntity.status(status).body(JSONResponse.builder().success(success).code(status.value()).message(message).build());
+ }
+}
diff --git a/src/test/java/com/digitalsanctuary/spring/demo/controller/AdminAPIControllerTest.java b/src/test/java/com/digitalsanctuary/spring/demo/controller/AdminAPIControllerTest.java
new file mode 100644
index 0000000..f61e215
--- /dev/null
+++ b/src/test/java/com/digitalsanctuary/spring/demo/controller/AdminAPIControllerTest.java
@@ -0,0 +1,143 @@
+package com.digitalsanctuary.spring.demo.controller;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+import java.util.ArrayList;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.http.MediaType;
+import org.springframework.security.test.context.support.WithMockUser;
+import org.springframework.test.web.servlet.MockMvc;
+import com.digitalsanctuary.spring.user.persistence.model.User;
+import com.digitalsanctuary.spring.user.persistence.repository.UserRepository;
+import com.digitalsanctuary.spring.user.test.annotations.IntegrationTest;
+import com.digitalsanctuary.spring.user.test.builders.UserTestDataBuilder;
+import jakarta.persistence.EntityManager;
+
+/**
+ * Covers the admin lock/unlock endpoints that back src/main/resources/static/js/admin/admin-action.js.
+ */
+@IntegrationTest
+@DisplayName("Admin Lock/Unlock API Tests")
+class AdminAPIControllerTest {
+
+ private static final String LOCK_URI = "/admin/lockAccount";
+ private static final String UNLOCK_URI = "/admin/unlockAccount";
+ private static final String TARGET_EMAIL = "admin.action.target@example.com";
+
+ @Autowired
+ private MockMvc mockMvc;
+
+ @Autowired
+ private UserRepository userRepository;
+
+ @Autowired
+ private EntityManager entityManager;
+
+ @BeforeEach
+ void setUp() {
+ User existing = userRepository.findByEmail(TARGET_EMAIL);
+ if (existing != null) {
+ userRepository.delete(existing);
+ entityManager.flush();
+ }
+ }
+
+ /** Persists the target user inside the test transaction so it rolls back cleanly. */
+ private User saveTargetUser(UserTestDataBuilder builder) {
+ User user = builder.withEmail(TARGET_EMAIL).withFirstName("Target").withLastName("User").verified().withId(null).build();
+ user.setRoles(new ArrayList<>());
+ User saved = userRepository.save(user);
+ entityManager.flush();
+ return saved;
+ }
+
+ /** Re-reads the target user from the database, bypassing the first level cache. */
+ private User reloadTargetUser() {
+ entityManager.flush();
+ entityManager.clear();
+ return userRepository.findByEmail(TARGET_EMAIL);
+ }
+
+ private static String body(String email) {
+ return "{\"email\":\"" + email + "\"}";
+ }
+
+ @Test
+ @DisplayName("Admin can lock an account")
+ @WithMockUser(username = "admin@example.com", authorities = {"ADMIN_PRIVILEGE"})
+ void adminCanLockAccount() throws Exception {
+ saveTargetUser(UserTestDataBuilder.aUser().unlocked());
+
+ mockMvc.perform(post(LOCK_URI).contentType(MediaType.APPLICATION_JSON).content(body(TARGET_EMAIL)).with(csrf()))
+ .andExpect(status().isOk()).andExpect(jsonPath("$.success").value(true))
+ .andExpect(jsonPath("$.messages[0]").value("Account locked."));
+
+ User locked = reloadTargetUser();
+ assertThat(locked.isLocked()).isTrue();
+ assertThat(locked.getLockedDate()).isNotNull();
+ }
+
+ @Test
+ @DisplayName("Admin can unlock an account")
+ @WithMockUser(username = "admin@example.com", authorities = {"ADMIN_PRIVILEGE"})
+ void adminCanUnlockAccount() throws Exception {
+ saveTargetUser(UserTestDataBuilder.aUser().locked().withFailedLoginAttempts(5));
+
+ mockMvc.perform(post(UNLOCK_URI).contentType(MediaType.APPLICATION_JSON).content(body(TARGET_EMAIL)).with(csrf()))
+ .andExpect(status().isOk()).andExpect(jsonPath("$.success").value(true))
+ .andExpect(jsonPath("$.messages[0]").value("Account unlocked."));
+
+ User unlocked = reloadTargetUser();
+ assertThat(unlocked.isLocked()).isFalse();
+ assertThat(unlocked.getLockedDate()).isNull();
+ assertThat(unlocked.getFailedLoginAttempts()).isZero();
+ }
+
+ @Test
+ @DisplayName("Unknown email returns a not found JSON response")
+ @WithMockUser(username = "admin@example.com", authorities = {"ADMIN_PRIVILEGE"})
+ void unknownEmailReturnsNotFound() throws Exception {
+ mockMvc.perform(post(LOCK_URI).contentType(MediaType.APPLICATION_JSON).content(body("nobody@example.com")).with(csrf()))
+ .andExpect(status().isNotFound()).andExpect(jsonPath("$.success").value(false))
+ .andExpect(jsonPath("$.messages[0]").value("User not found."));
+ }
+
+ @Test
+ @DisplayName("Blank email returns a bad request JSON response")
+ @WithMockUser(username = "admin@example.com", authorities = {"ADMIN_PRIVILEGE"})
+ void blankEmailReturnsBadRequest() throws Exception {
+ mockMvc.perform(post(UNLOCK_URI).contentType(MediaType.APPLICATION_JSON).content(body(" ")).with(csrf()))
+ .andExpect(status().isBadRequest()).andExpect(jsonPath("$.success").value(false))
+ .andExpect(jsonPath("$.messages[0]").value("Email is required."));
+ }
+
+ @Test
+ @DisplayName("Non-admin gets 403 on lock")
+ @WithMockUser(username = "user@example.com", authorities = {"LOGIN_PRIVILEGE"})
+ void nonAdminCannotLock() throws Exception {
+ saveTargetUser(UserTestDataBuilder.aUser().unlocked());
+
+ mockMvc.perform(post(LOCK_URI).contentType(MediaType.APPLICATION_JSON).content(body(TARGET_EMAIL)).with(csrf()))
+ .andExpect(status().isForbidden());
+
+ assertThat(reloadTargetUser().isLocked()).isFalse();
+ }
+
+ @Test
+ @DisplayName("Non-admin gets 403 on unlock")
+ @WithMockUser(username = "user@example.com", authorities = {"LOGIN_PRIVILEGE"})
+ void nonAdminCannotUnlock() throws Exception {
+ saveTargetUser(UserTestDataBuilder.aUser().locked());
+
+ mockMvc.perform(post(UNLOCK_URI).contentType(MediaType.APPLICATION_JSON).content(body(TARGET_EMAIL)).with(csrf()))
+ .andExpect(status().isForbidden());
+
+ assertThat(reloadTargetUser().isLocked()).isTrue();
+ }
+}
From 02bc99dc1ea00e78bcc70920a24f4f9fc0d06fbc Mon Sep 17 00:00:00 2001
From: Devon Hillard
Date: Sat, 15 Aug 2026 00:56:16 -0600
Subject: [PATCH 10/26] docs: add configuration and development guides
---
docs/CONFIGURATION.md | 91 +++++++++++++++++++++++++++++++++++++++
docs/DEVELOPMENT.md | 99 +++++++++++++++++++++++++++++++++++++++++++
2 files changed, 190 insertions(+)
create mode 100644 docs/CONFIGURATION.md
create mode 100644 docs/DEVELOPMENT.md
diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md
new file mode 100644
index 0000000..6a9b3d5
--- /dev/null
+++ b/docs/CONFIGURATION.md
@@ -0,0 +1,91 @@
+# Configuration
+
+Base [`application.yml`](../src/main/resources/application.yml) holds the framework defaults this
+demo runs with: mail transport, datasource, session/security settings, role/privilege map, and the
+Docker Compose integration used by `bootRun`. Each profile file below overrides a subset of those
+values for one scenario (local dev, production, tests, and so on). For the full property reference
+(every key the framework recognizes, not just the ones this demo sets), see the framework's
+[CONFIG.md](https://github.com/devondragon/SpringUserFramework/blob/main/CONFIG.md).
+
+## Profiles
+
+`local`, `dev`, `prd`, `test`, `playwright-test`, and `docker-keycloak` are base profiles: pick one.
+`mfa` and `registration-guard` are opt-in add-ons with no base settings of their own; activate one
+alongside a base profile by listing both, comma-separated, in `--spring.profiles.active` (Spring
+Boot applies later profiles' properties over earlier ones when the same key is set in both).
+
+| Profile | File | Purpose | What it overrides | Activate |
+| --- | --- | --- | --- | --- |
+| `local` | [`application-local.yml-example`](../src/main/resources/application-local.yml-example) → `application-local.yml` (gitignored, you create it) | Everyday local development | Debug logging, DevTools restart/LiveReload, seed-data loading, example OAuth2 client registrations, `sendVerificationEmail: false` | `--spring.profiles.active=local` |
+| `dev` | [`application-dev.yml`](../src/main/resources/application-dev.yml) | Debug-heavy dev server; also what the Docker demo stack (`compose.yaml`) runs the app container as | Debug logging, insecure session cookie, `audit.flushOnWrite: true` | `--spring.profiles.active=dev` |
+| `prd` | [`application-prd.yml`](../src/main/resources/application-prd.yml) | Production | Thymeleaf caching on, `ddl-auto: validate`, env-driven datasource, strict/secure cookies, `WARN` logging, limited actuator exposure, env-driven WebAuthn RP identity and `appUrl`, `requireCanonicalAppUrl: true`, no fallback for the remember-me key | `--spring.profiles.active=prd` |
+| `test` | [`src/test/resources/application-test.properties`](../src/test/resources/application-test.properties) | Automated JUnit suite | Per-context isolated H2 database, MFA off, 3-attempt lockout, test-only `unprotectedURIs` | Applied automatically by `./gradlew test` |
+| `playwright-test` | [`application-playwright-test.yml`](../src/main/resources/application-playwright-test.yml) | Playwright E2E runs; enables the Test API (`TestDataController`, `TestApiSecurityConfig`, localhost-only) | Disables verification/reset emails, pins `appUrl` to `http://localhost:8080`, `allowInitialPasswordSetWithoutStepUp: true`, MFA off | Combine with a base profile, e.g. `local,playwright-test` (see [TESTING.md](TESTING.md)) |
+| `docker-keycloak` | [`application-docker-keycloak.yml-example`](../src/main/resources/application-docker-keycloak.yml-example) → `application-docker-keycloak.yml` (gitignored) | OIDC login against the bundled Keycloak stack | Keycloak OAuth2 client/provider from `DS_SPRING_USER_KEYCLOAK_*` env vars, insecure session cookie, `audit.flushOnWrite: true` | `--spring.profiles.active=docker-keycloak`, normally set for you as `SPRING_PROFILES_ACTIVE` inside `docker-compose-keycloak.yml` |
+| `mfa` | [`application-mfa.yml`](../src/main/resources/application-mfa.yml) | Add-on: require PASSWORD + WEBAUTHN | `user.mfa.enabled: true`, unprotects the WebAuthn challenge/enrollment endpoints, `allowInitialPasswordSetWithoutStepUp: true` | Combine with a base profile, e.g. `local,mfa` |
+| `registration-guard` | none (no yml; `@Profile("registration-guard")` on [`DomainRegistrationGuard`](../src/main/java/com/digitalsanctuary/spring/demo/registration/DomainRegistrationGuard.java)) | Add-on: domain-restricted registration demo | Activates a `RegistrationGuard` bean that restricts form/passwordless registration to one email domain (`registration.guard.allowed-domain`, default `@example.com`); OAuth2/OIDC registration is unaffected | Combine with a base profile, e.g. `local,registration-guard` |
+
+See [AUTHENTICATION.md](AUTHENTICATION.md) for the mechanics behind `mfa`
+([#mfa](AUTHENTICATION.md#mfa)), `docker-keycloak` ([#keycloak](AUTHENTICATION.md#keycloak)),
+WebAuthn passkeys ([#passkeys](AUTHENTICATION.md#passkeys)), and `registration-guard`
+([#registration-guard](AUTHENTICATION.md#registration-guard)).
+
+## Getting started locally
+
+1. Copy the example file and edit it: `cp src/main/resources/application-local.yml-example src/main/resources/application-local.yml`. It is gitignored, so your edits (and any real credentials) never get committed.
+2. This step matters: base `application.yml` leaves `user.registration.sendVerificationEmail: true` (`application.yml:113`) and points `spring.mail.host` at an SES endpoint with no credentials (`application.yml:2-6`), so a fresh clone that skips step 1 starts fine but can never send the verification email a new registration needs, and you cannot log in. `application-local.yml-example` sets `sendVerificationEmail: false` (`application-local.yml-example:128`), so once you copy it, registered accounts are enabled immediately. To exercise the real verification flow instead, set it back to `true` and point `spring.mail.*` at a real SMTP server. The Docker demo stack (`compose.yaml`) disables verification email the same way; see [Mail](#mail).
+3. At minimum, set `spring.mail.username`, `spring.mail.password`, and `spring.mail.host` if you want outbound mail to work locally. Set the `spring.security.oauth2.client.registration.*` client IDs/secrets only if you want to exercise social/Keycloak login.
+4. Docker Compose integration: base `application.yml` sets `spring.docker.compose.file: compose.dev.yaml` (lines 66-73), so `./gradlew bootRun` under any profile starts a MariaDB 12.2 container (`springuser`/`springuser`, port 3306) automatically and stops it when the app stops. Set `spring.docker.compose.enabled: false` (commented hint right below the `file:` line) to point at a database you manage yourself instead.
+5. Seed data: `application-local.yml-example` sets `spring.sql.init.mode: always` and `spring.sql.init.platform: local`, plus `spring.jpa.defer-datasource-initialization: true` (lines 25-30), so every boot under the `local` profile loads [`data-local.sql`](../src/main/resources/data-local.sql) (sample events). The script uses `INSERT IGNORE`, so re-running it on every start is safe.
+
+## Environment variables
+
+Required, no fallback:
+
+| Variable | Meaning |
+| --- | --- |
+| `REMEMBER_ME_KEY` | Signs remember-me tokens. Base `application.yml` falls back to a random UUID per boot (`application.yml:157`) so the demo runs without it, but `application-prd.yml:57` has no fallback: `prd` fails to start unless this is set. |
+
+Recognized elsewhere (fall back to a demo default when unset):
+
+| Variable | Meaning |
+| --- | --- |
+| `APP_URL` | Canonical base URL for security email links in `prd` (`application-prd.yml:47`, default `https://example.com`). |
+| `DATABASE_URL`, `DATABASE_USERNAME`, `DATABASE_PASSWORD` | Production datasource (`application-prd.yml:10-12`). |
+| `WEBAUTHN_RP_ID`, `WEBAUTHN_RP_NAME`, `WEBAUTHN_ALLOWED_ORIGINS` | WebAuthn relying-party identity in `prd` (`application-prd.yml:41-43`). |
+| `DS_SPRING_USER_KEYCLOAK_CLIENT_ID`, `_CLIENT_SECRET`, `_PROVIDER_ISSUER_URI`, `_PROVIDER_AUTHORIZATION_URI`, `_PROVIDER_TOKEN_URI`, `_PROVIDER_USER_INFO_URI`, `_PROVIDER_JWK_SET_URI` | Keycloak OAuth2 client and provider endpoints for `docker-keycloak` (`application-docker-keycloak.yml-example:29-45`); supplied by [`keycloak.env`](../keycloak.env) when you run `docker-compose-keycloak.yml`. |
+| `SELINUX_LABEL` | Suffix on the mailserver's bind-mounted config path in `compose.yaml`/`docker-compose-keycloak.yml`. Unset by default; Docker Compose prints a harmless warning about it. |
+
+Any framework property can also be set through Spring's relaxed binding (`SCREAMING_SNAKE_CASE` of
+the dotted key). The Docker demo stack (`compose.yaml`) does this for the app container:
+`SPRING_DATASOURCE_URL`/`_USERNAME`/`_PASSWORD` (→ `spring.datasource.*`), `SPRING_PROFILES_ACTIVE`,
+`SPRING_MAIL_HOST`/`_PORT` and the `SPRING_MAIL_PROPERTIES_MAIL_SMTP_*` keys (→ `spring.mail.*`), and
+`USER_REGISTRATION_SENDVERIFICATIONEMAIL` (→ `user.registration.sendVerificationEmail`). The same
+pattern works for any other key, e.g. `USER_SECURITY_BCRYPTSTRENGTH` for `user.security.bcryptStrength`.
+
+## Mail
+
+- `spring.mail.username`, `spring.mail.password`, `spring.mail.host`, `spring.mail.port` (`application.yml:2-6`) configure the SMTP transport used for verification, password-reset, and notification email. The base file's `host` is a placeholder SES endpoint; set real credentials in your profile.
+- `user.registration.sendVerificationEmail` (`application.yml:113`) controls whether a new account must click a verification link before it can log in. `false` enables the account immediately at registration.
+- The Docker demo stack's `mailserver` service (`compose.yaml`) is a relay only: `SMTP_ONLY: 1` (`compose.yaml:48`) with no route to real inboxes. That stack sets `USER_REGISTRATION_SENDVERIFICATIONEMAIL: "false"` (`compose.yaml:85`) so registered accounts activate immediately instead of waiting on mail nothing will deliver.
+- `user.mail.fromAddress` sets the `From` address on outbound mail; it is set per profile (e.g. `application-local.yml-example:141`), not in the base file.
+
+## Security settings this demo sets
+
+- **Bcrypt strength**: `user.security.bcryptStrength: 12` (`application.yml:148`); `testHashTime: true` (`application.yml:149`) logs the measured hash time at startup so you can tune it.
+- **Failed-login lockout**: `failedLoginAttempts: 10`, `accountLockoutDuration: 30` minutes (`application.yml:146-147`).
+- **Session timeout**: `server.servlet.session.timeout: 30m`, with `secure` and `http-only` cookie flags (`application.yml:92-96`).
+- **Default action / protected surface**: `defaultAction: deny` with an explicit `unprotectedURIs` allowlist, plus `protectedURIs` and `disableCSRFdURIs` (`application.yml:150-162`).
+- **Remember-me**: `rememberMe.enabled: true`, signing key from `REMEMBER_ME_KEY` with a random-UUID fallback (`application.yml:151-158`).
+
+For OAuth2/OIDC, WebAuthn passkeys, MFA, and the registration guard, see
+[AUTHENTICATION.md](AUTHENTICATION.md).
+
+## Roles and monitoring
+
+`user.roles.roles-and-privileges` and `user.roles.role-hierarchy` (`application.yml:200-222`) define
+this demo's `ROLE_ADMIN` > `ROLE_MANAGER` > `ROLE_USER` hierarchy and the privileges behind each of
+the demo's event-management and user-management actions; edit them in place if you add roles or
+privileges. `management.newrelic.metrics.export.api-key` / `.account-id` (`application.yml:83-87`)
+are unset placeholders: leave them blank to skip New Relic, or fill them in per profile to export
+metrics.
diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md
new file mode 100644
index 0000000..2097f7f
--- /dev/null
+++ b/docs/DEVELOPMENT.md
@@ -0,0 +1,99 @@
+# Development
+
+## Prerequisites
+
+- JDK 21. [`mise.toml`](../mise.toml) pins `java = "21"` if you use [mise](https://mise.jdx.dev/).
+- Docker. `./gradlew bootRun` starts a MariaDB container for you (see below); Docker must be running.
+- Node.js, only if you run the Playwright E2E suite; see [TESTING.md](TESTING.md).
+
+## Running the app
+
+```bash
+./gradlew bootRun --args='--spring.profiles.active=local'
+```
+
+This is the Spring Boot Gradle plugin's `bootRun` task. Add `--debug-jvm` to attach a debugger on
+port 5005. Because the `spring.docker.compose.file: compose.dev.yaml` setting lives in base
+`application.yml` (`application.yml:66-73`), `bootRun` always starts a MariaDB 12.2 container
+(`springuser`/`springuser`, port 3306) automatically, whichever profile you pass, and stops it when
+you stop the app. See [CONFIGURATION.md](CONFIGURATION.md) for what to edit first
+(`application-local.yml`) and how to opt out of the auto-started database.
+
+[`scripts/run.sh`](../scripts/run.sh) is a different path: it runs `./gradlew bootJar`, then
+`java -agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=*:6332 -jar
+build/libs/ds-spring-user-framework-demo-1.0.1-SNAPSHOT.jar --spring.profiles.active=local` (JDWP
+debug agent on port 6332). Running from the packaged jar means `spring-boot-docker-compose` is not
+on the classpath (it is `developmentOnly`), so the `spring.docker.compose.file` setting has no effect
+here and no database gets started for you. Start one first, either
+`docker compose -f compose.dev.yaml up -d` or your own MariaDB on `localhost:3306`, then run
+`./scripts/run.sh`.
+
+Spring Boot DevTools (`runtimeOnly` dependency) restarts the app automatically when a class changes,
+in either run mode.
+
+### LiveReload
+
+The LiveReload `-->
+```
+
+Uncomment it to enable browser auto-refresh on template/static changes. For plain HTTP this works
+with DevTools' built-in LiveReload server (port 35729) as-is. For HTTPS local development, set
+`spring.devtools.livereload.https=true` in your profile (`application-local.yml` or
+`application-docker-keycloak.yml`) and put a reverse proxy in front of the LiveReload port, e.g.
+`mitmproxy --mode reverse:http://localhost:35729 -p 35739`. `.vscode/tasks.json` has "Start ngrok" and
+"Start mitmproxy" tasks (composed as "Start Dev Tools") that automate the tunnel + proxy pair. See:
+
+- [Spring Boot Live Reload](https://www.digitalsanctuary.com/java/springboot-devtools-auto-restart-and-live-reload.html)
+- [HTTPS Live Reload Setup](https://www.digitalsanctuary.com/java/how-to-get-springboot-livereload-working-over-https.html)
+
+## Docker Compose files
+
+- [`compose.dev.yaml`](../compose.dev.yaml): database only. Started automatically by `bootRun`'s
+ Docker Compose integration; not meant to be run directly, though `docker compose -f compose.dev.yaml
+ up -d` works if you want the database without the app.
+- [`compose.yaml`](../compose.yaml): the full demo stack: app + MariaDB + a relay-only mail
+ container. `docker compose up -d` builds the app image (multi-stage [`Dockerfile`](../Dockerfile):
+ a JDK-21 build stage runs `./gradlew bootJar`, a JRE-21 stage runs the resulting jar as a non-root
+ user) and runs all three. The app container's healthcheck polls `GET /actuator/health`
+ (`compose.yaml:86-90`), the only actuator endpoint left unauthenticated.
+- [`docker-compose-keycloak.yml`](../docker-compose-keycloak.yml): the same app + MariaDB + mail
+ setup plus a Keycloak container, for testing OIDC login. See [AUTHENTICATION.md](AUTHENTICATION.md#keycloak).
+
+## Gradle tasks
+
+- `./gradlew test`: run the JUnit suite (`test` profile, H2 in-memory).
+- `./gradlew bootJar`: build the executable jar.
+- `./gradlew build -x test`: full build, skipping tests.
+- `./gradlew dependencyUpdates`: report outdated dependencies (`com.github.ben-manes.versions` plugin).
+- `./gradlew playwrightInstall` / `playwrightBrowsers` / `playwrightTest` / `playwrightTestChromium` /
+ `playwrightReport`: Playwright E2E tasks, defined in `build.gradle`; see [TESTING.md](TESTING.md)
+ for what each does and how they're wired together.
+
+Run `./gradlew tasks --group verification` to list the verification-group tasks (including the
+Playwright ones above) straight from the build.
+
+## Logs
+
+The app writes to `/opt/app/logs/user-app.log` (`application.yml:97-99`; `application-prd.yml:22-28`
+sets `WARN`-level logging for the same file in `prd`). Security/user-lifecycle events go to a
+separate audit log at `/opt/app/logs/user-audit.log` (`application.yml:135-138`,
+`user.audit.logFilePath`).
+
+## API surface
+
+Swagger UI is at `/swagger-ui.html` (`springdoc.swagger-ui.path`, `application.yml:101-107`), scanning
+`com.digitalsanctuary.spring.demo` and `com.digitalsanctuary.spring.user`. It documents this app's own
+endpoints (e.g. [`EventAPIController`](../src/main/java/com/digitalsanctuary/spring/demo/event/EventAPIController.java)).
+For the framework's `/user/*` endpoints (registration, login, password reset, profile update), see
+the framework's [User Management docs](https://github.com/devondragon/SpringUserFramework#user-management).
+
+## IDE setup
+
+Enable annotation processing (Lombok is a `compileOnly` + `annotationProcessor` dependency; without
+it the project won't compile in the IDE). `.vscode/tasks.json` has the ngrok/mitmproxy tasks used for
+HTTPS LiveReload above.
From e7fdac9e1133b86138322250bdd214facf28e9a3 Mon Sep 17 00:00:00 2001
From: Devon Hillard
Date: Sat, 15 Aug 2026 01:07:36 -0600
Subject: [PATCH 11/26] docs: fix reviewer findings in configuration and
development guides
---
docs/CONFIGURATION.md | 25 ++++++++++++++++++-------
docs/DEVELOPMENT.md | 30 ++++++++++++++++++++++--------
2 files changed, 40 insertions(+), 15 deletions(-)
diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md
index 6a9b3d5..e5baba0 100644
--- a/docs/CONFIGURATION.md
+++ b/docs/CONFIGURATION.md
@@ -9,20 +9,29 @@ values for one scenario (local dev, production, tests, and so on). For the full
## Profiles
-`local`, `dev`, `prd`, `test`, `playwright-test`, and `docker-keycloak` are base profiles: pick one.
-`mfa` and `registration-guard` are opt-in add-ons with no base settings of their own; activate one
-alongside a base profile by listing both, comma-separated, in `--spring.profiles.active` (Spring
-Boot applies later profiles' properties over earlier ones when the same key is set in both).
+```bash
+./gradlew bootRun --args='--spring.profiles.active=local'
+```
+
+`local`, `dev`, `prd`, and `docker-keycloak` are base profiles you choose directly, one at a time, the
+way the command above chooses `local`. `test` is not chosen by hand: `./gradlew test` applies it
+automatically. `playwright-test` is meant to be combined with a base profile rather than run alone
+(see its row below). `mfa` and `registration-guard` are opt-in add-ons with no base settings of their
+own; combine one with a base profile by listing both, comma-separated, in `--spring.profiles.active`
+(Spring Boot applies later profiles' properties over earlier ones when the same key is set in both).
+If you omit `--args` entirely, `bootRun` still defaults to `local`: `build.gradle:118-123` sets
+`SPRING_PROFILES_ACTIVE=local` unless you pass a Gradle project property, e.g.
+`./gradlew bootRun -Pprofiles=local,mfa`.
| Profile | File | Purpose | What it overrides | Activate |
| --- | --- | --- | --- | --- |
-| `local` | [`application-local.yml-example`](../src/main/resources/application-local.yml-example) → `application-local.yml` (gitignored, you create it) | Everyday local development | Debug logging, DevTools restart/LiveReload, seed-data loading, example OAuth2 client registrations, `sendVerificationEmail: false` | `--spring.profiles.active=local` |
+| `local` | [`application-local.yml-example`](../src/main/resources/application-local.yml-example) → `application-local.yml` (gitignored, you create it) | Everyday local development | Debug logging, DevTools restart/LiveReload, seed-data loading, example OAuth2 client registrations, `sendVerificationEmail: false`, `allowInitialPasswordSetWithoutStepUp: true` | `--spring.profiles.active=local` |
| `dev` | [`application-dev.yml`](../src/main/resources/application-dev.yml) | Debug-heavy dev server; also what the Docker demo stack (`compose.yaml`) runs the app container as | Debug logging, insecure session cookie, `audit.flushOnWrite: true` | `--spring.profiles.active=dev` |
| `prd` | [`application-prd.yml`](../src/main/resources/application-prd.yml) | Production | Thymeleaf caching on, `ddl-auto: validate`, env-driven datasource, strict/secure cookies, `WARN` logging, limited actuator exposure, env-driven WebAuthn RP identity and `appUrl`, `requireCanonicalAppUrl: true`, no fallback for the remember-me key | `--spring.profiles.active=prd` |
-| `test` | [`src/test/resources/application-test.properties`](../src/test/resources/application-test.properties) | Automated JUnit suite | Per-context isolated H2 database, MFA off, 3-attempt lockout, test-only `unprotectedURIs` | Applied automatically by `./gradlew test` |
+| `test` | [`src/test/resources/application-test.properties`](../src/test/resources/application-test.properties) | Automated JUnit suite | Per-context isolated H2 database, MFA off, test-only `unprotectedURIs`. It also sets `maxFailedLoginAttempts`/`lockoutDurationMinutes` (lines 16-17), but those aren't the framework's property names (`failedLoginAttempts`/`accountLockoutDuration`), so they don't bind; lockout stays at the inherited default (10 attempts / 30 min) | Applied automatically by `./gradlew test` |
| `playwright-test` | [`application-playwright-test.yml`](../src/main/resources/application-playwright-test.yml) | Playwright E2E runs; enables the Test API (`TestDataController`, `TestApiSecurityConfig`, localhost-only) | Disables verification/reset emails, pins `appUrl` to `http://localhost:8080`, `allowInitialPasswordSetWithoutStepUp: true`, MFA off | Combine with a base profile, e.g. `local,playwright-test` (see [TESTING.md](TESTING.md)) |
| `docker-keycloak` | [`application-docker-keycloak.yml-example`](../src/main/resources/application-docker-keycloak.yml-example) → `application-docker-keycloak.yml` (gitignored) | OIDC login against the bundled Keycloak stack | Keycloak OAuth2 client/provider from `DS_SPRING_USER_KEYCLOAK_*` env vars, insecure session cookie, `audit.flushOnWrite: true` | `--spring.profiles.active=docker-keycloak`, normally set for you as `SPRING_PROFILES_ACTIVE` inside `docker-compose-keycloak.yml` |
-| `mfa` | [`application-mfa.yml`](../src/main/resources/application-mfa.yml) | Add-on: require PASSWORD + WEBAUTHN | `user.mfa.enabled: true`, unprotects the WebAuthn challenge/enrollment endpoints, `allowInitialPasswordSetWithoutStepUp: true` | Combine with a base profile, e.g. `local,mfa` |
+| `mfa` | [`application-mfa.yml`](../src/main/resources/application-mfa.yml) | Add-on: require PASSWORD + WEBAUTHN | `user.mfa.enabled: true`; adds the passkey enrollment endpoints `/webauthn/register/options` and `/webauthn/register` to `unprotectedURIs` (line 25) so a partially-authenticated user can register their first passkey (the MFA challenge page itself is already unprotected in base `application.yml`, and the framework auto-unprotects the configured entry-point URIs); `allowInitialPasswordSetWithoutStepUp: true` | Combine with a base profile, e.g. `local,mfa` |
| `registration-guard` | none (no yml; `@Profile("registration-guard")` on [`DomainRegistrationGuard`](../src/main/java/com/digitalsanctuary/spring/demo/registration/DomainRegistrationGuard.java)) | Add-on: domain-restricted registration demo | Activates a `RegistrationGuard` bean that restricts form/passwordless registration to one email domain (`registration.guard.allowed-domain`, default `@example.com`); OAuth2/OIDC registration is unaffected | Combine with a base profile, e.g. `local,registration-guard` |
See [AUTHENTICATION.md](AUTHENTICATION.md) for the mechanics behind `mfa`
@@ -77,6 +86,8 @@ pattern works for any other key, e.g. `USER_SECURITY_BCRYPTSTRENGTH` for `user.s
- **Session timeout**: `server.servlet.session.timeout: 30m`, with `secure` and `http-only` cookie flags (`application.yml:92-96`).
- **Default action / protected surface**: `defaultAction: deny` with an explicit `unprotectedURIs` allowlist, plus `protectedURIs` and `disableCSRFdURIs` (`application.yml:150-162`).
- **Remember-me**: `rememberMe.enabled: true`, signing key from `REMEMBER_ME_KEY` with a random-UUID fallback (`application.yml:151-158`).
+- **Canonical app URL** (`user.security.appUrl`): prevents Host-header poisoning of password-reset/verification email links (SUF-01 / CWE-640); when unset, the framework derives the host from the (spoofable) request `Host` header and logs a startup warning. Base `application.yml:145` sets `http://localhost:8080`; `prd` drives it from `${APP_URL:https://example.com}` (`application-prd.yml:47`) and also sets `requireCanonicalAppUrl: true` (`application-prd.yml:49`), so `prd` fails to start without it; `playwright-test` pins it explicitly to `http://localhost:8080` (`application-playwright-test.yml:32`).
+- **Allow initial password set without step-up** (`user.security.allowInitialPasswordSetWithoutStepUp`): controls `POST /user/setPassword`, which lets a passkey-only account set an initial password. As of the framework's SUF-02 hardening, this endpoint returns `403` unless a `StepUpService` bean exists or this is `true`. This demo has no `StepUpService`, so it sets the flag `true` in `local` (`application-local.yml-example:138`), `mfa` (`application-mfa.yml:24`), and `playwright-test` (`application-playwright-test.yml:35`) to keep the passkey flow usable, and leaves it at its secure default `false` in `prd` (`application-prd.yml:50`).
For OAuth2/OIDC, WebAuthn passkeys, MFA, and the registration guard, see
[AUTHENTICATION.md](AUTHENTICATION.md).
diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md
index 2097f7f..49e548c 100644
--- a/docs/DEVELOPMENT.md
+++ b/docs/DEVELOPMENT.md
@@ -29,7 +29,8 @@ here and no database gets started for you. Start one first, either
`./scripts/run.sh`.
Spring Boot DevTools (`runtimeOnly` dependency) restarts the app automatically when a class changes,
-in either run mode.
+but only under `bootRun`. It disables itself when the app is launched as a fully packaged jar via
+`java -jar`, which is exactly what `scripts/run.sh` does, so that path has no auto-restart.
### LiveReload
@@ -41,12 +42,25 @@ The LiveReload `-->
```
-Uncomment it to enable browser auto-refresh on template/static changes. For plain HTTP this works
-with DevTools' built-in LiveReload server (port 35729) as-is. For HTTPS local development, set
-`spring.devtools.livereload.https=true` in your profile (`application-local.yml` or
-`application-docker-keycloak.yml`) and put a reverse proxy in front of the LiveReload port, e.g.
-`mitmproxy --mode reverse:http://localhost:35729 -p 35739`. `.vscode/tasks.json` has "Start ngrok" and
-"Start mitmproxy" tasks (composed as "Start Dev Tools") that automate the tunnel + proxy pair. See:
+Uncomment it to enable browser auto-refresh on template/static changes, but note the URL is hardcoded
+to `https://`, not conditional on scheme. The port comes from the framework's
+[`LiveReloadGlobalControllerAdvice`](https://github.com/devondragon/SpringUserFramework/blob/main/src/main/java/com/digitalsanctuary/spring/user/util/LiveReloadGlobalControllerAdvice.java#L23-L37):
+35739 when `spring.devtools.livereload.https=true`, 35729 otherwise. The real DevTools LiveReload
+server always listens on plain HTTP at its default port, 35729, regardless of that flag, so:
+
+- `spring.devtools.livereload.https=true`, already set by
+ `application-local.yml-example:107` and `application-docker-keycloak.yml-example:60` (so copying
+ either example file puts you on this path immediately): the script requests
+ `https://localhost:35739/livereload.js`. Nothing listens there by default; run
+ `mitmproxy --mode reverse:http://localhost:35729 -p 35739` to terminate TLS on 35739 and forward to
+ the real server on 35729.
+- `spring.devtools.livereload.https=false` (the property's own default): the script requests
+ `https://localhost:35729/livereload.js`, HTTPS against a server that only speaks plain HTTP, which
+ does not connect. Uncommenting the tag with this setting does not work without also changing the
+ template or running a proxy in front of 35729.
+
+`.vscode/tasks.json` has "Start ngrok" and "Start mitmproxy" tasks (composed as "Start Dev Tools")
+that automate the tunnel + proxy pair for the first case. See:
- [Spring Boot Live Reload](https://www.digitalsanctuary.com/java/springboot-devtools-auto-restart-and-live-reload.html)
- [HTTPS Live Reload Setup](https://www.digitalsanctuary.com/java/how-to-get-springboot-livereload-working-over-https.html)
@@ -58,7 +72,7 @@ with DevTools' built-in LiveReload server (port 35729) as-is. For HTTPS local de
up -d` works if you want the database without the app.
- [`compose.yaml`](../compose.yaml): the full demo stack: app + MariaDB + a relay-only mail
container. `docker compose up -d` builds the app image (multi-stage [`Dockerfile`](../Dockerfile):
- a JDK-21 build stage runs `./gradlew bootJar`, a JRE-21 stage runs the resulting jar as a non-root
+ a JDK-21 build stage runs `./gradlew --no-daemon bootJar -x test` (`Dockerfile:14`), a JRE-21 stage runs the resulting jar as a non-root
user) and runs all three. The app container's healthcheck polls `GET /actuator/health`
(`compose.yaml:86-90`), the only actuator endpoint left unauthenticated.
- [`docker-compose-keycloak.yml`](../docker-compose-keycloak.yml): the same app + MariaDB + mail
From d3d83f3fffae4685a301e8a11b14d12099b72723 Mon Sep 17 00:00:00 2001
From: Devon Hillard
Date: Sat, 15 Aug 2026 01:11:50 -0600
Subject: [PATCH 12/26] docs: fix mfa row's challenge-page unprotect claim
---
docs/CONFIGURATION.md | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md
index e5baba0..b4ac89c 100644
--- a/docs/CONFIGURATION.md
+++ b/docs/CONFIGURATION.md
@@ -31,7 +31,7 @@ If you omit `--args` entirely, `bootRun` still defaults to `local`: `build.gradl
| `test` | [`src/test/resources/application-test.properties`](../src/test/resources/application-test.properties) | Automated JUnit suite | Per-context isolated H2 database, MFA off, test-only `unprotectedURIs`. It also sets `maxFailedLoginAttempts`/`lockoutDurationMinutes` (lines 16-17), but those aren't the framework's property names (`failedLoginAttempts`/`accountLockoutDuration`), so they don't bind; lockout stays at the inherited default (10 attempts / 30 min) | Applied automatically by `./gradlew test` |
| `playwright-test` | [`application-playwright-test.yml`](../src/main/resources/application-playwright-test.yml) | Playwright E2E runs; enables the Test API (`TestDataController`, `TestApiSecurityConfig`, localhost-only) | Disables verification/reset emails, pins `appUrl` to `http://localhost:8080`, `allowInitialPasswordSetWithoutStepUp: true`, MFA off | Combine with a base profile, e.g. `local,playwright-test` (see [TESTING.md](TESTING.md)) |
| `docker-keycloak` | [`application-docker-keycloak.yml-example`](../src/main/resources/application-docker-keycloak.yml-example) → `application-docker-keycloak.yml` (gitignored) | OIDC login against the bundled Keycloak stack | Keycloak OAuth2 client/provider from `DS_SPRING_USER_KEYCLOAK_*` env vars, insecure session cookie, `audit.flushOnWrite: true` | `--spring.profiles.active=docker-keycloak`, normally set for you as `SPRING_PROFILES_ACTIVE` inside `docker-compose-keycloak.yml` |
-| `mfa` | [`application-mfa.yml`](../src/main/resources/application-mfa.yml) | Add-on: require PASSWORD + WEBAUTHN | `user.mfa.enabled: true`; adds the passkey enrollment endpoints `/webauthn/register/options` and `/webauthn/register` to `unprotectedURIs` (line 25) so a partially-authenticated user can register their first passkey (the MFA challenge page itself is already unprotected in base `application.yml`, and the framework auto-unprotects the configured entry-point URIs); `allowInitialPasswordSetWithoutStepUp: true` | Combine with a base profile, e.g. `local,mfa` |
+| `mfa` | [`application-mfa.yml`](../src/main/resources/application-mfa.yml) | Add-on: require PASSWORD + WEBAUTHN | `user.mfa.enabled: true` (base `application.yml:126` has it `false`); once enabled, the framework auto-unprotects the configured MFA entry-point URIs at runtime, including the challenge page, so a partially-authenticated user can reach them; the profile's yml additionally adds the passkey enrollment endpoints `/webauthn/register/options` and `/webauthn/register` to `unprotectedURIs` (line 25) so that user can register their first passkey; `allowInitialPasswordSetWithoutStepUp: true` | Combine with a base profile, e.g. `local,mfa` |
| `registration-guard` | none (no yml; `@Profile("registration-guard")` on [`DomainRegistrationGuard`](../src/main/java/com/digitalsanctuary/spring/demo/registration/DomainRegistrationGuard.java)) | Add-on: domain-restricted registration demo | Activates a `RegistrationGuard` bean that restricts form/passwordless registration to one email domain (`registration.guard.allowed-domain`, default `@example.com`); OAuth2/OIDC registration is unaffected | Combine with a base profile, e.g. `local,registration-guard` |
See [AUTHENTICATION.md](AUTHENTICATION.md) for the mechanics behind `mfa`
From d5a457cfea547d0f62f690337ed7ad844aeff97b Mon Sep 17 00:00:00 2001
From: Devon Hillard
Date: Sat, 15 Aug 2026 01:12:31 -0600
Subject: [PATCH 13/26] fix: make the keycloak oidc stack work from a host
browser
The stack could not complete an OIDC login. Four independent breakages:
- The realm export declared realm `master`. Keycloak creates the master realm
before `--import-realm` runs and the import strategy is IGNORE_EXISTING, so
the export (and the ds-spring-user-framework-demo client with it) was skipped
with only an INFO line. Retargeted the export to a `demo` realm, which does
not exist yet and therefore imports. Dropped the master-only pieces that
cannot be imported elsewhere (the master-realm client and its roles, the
admin/create-realm realm roles) and the client's authorization-services
settings, whose JS policy import is rejected by default.
- The client secret in the export was the masked `**********` and did not match
keycloak.env. Set it to the env value.
- The realm had no user with an email claim, which the framework's OIDC user
service requires. Added `demo` / `demo` with demo@example.com.
- All provider URIs pointed at keycloak:8080, which a host browser cannot
resolve. Only the authorization endpoint is browser-facing, so it now uses
the published localhost:8180; token, userinfo and JWK stay on the compose
network. KC_HOSTNAME pins the frontend URL (and so the token iss) and
KC_HOSTNAME_BACKCHANNEL_DYNAMIC keeps the backchannel on keycloak:8080.
issuer-uri is now unset: Spring Boot treats it as a discovery location, and no
single URL is reachable from both a host browser and the app container. The
consequence (Spring skips the iss comparison) is documented in the yml.
application-docker-keycloak.yml is now tracked and no longer dockerignored. It
holds only ${ENV_VAR} placeholders, and the image is built from src/, so
without it a fresh clone builds an image with no keycloak registration at all.
Also: the Keycloak healthcheck hit /auth/health/live, a path Keycloak 25 does
not serve, and the management port speaks HTTPS with a self-signed certificate
here, so it probes the demo realm's OIDC metadata instead. The app now waits
for Keycloak to be healthy.
Verified end to end: /oauth2/authorization/keycloak, sign in as demo/demo,
land on /index.html?messageKey=message.login.success signed in as Demo User,
with a user_account row (provider KEYCLOAK, enabled 1).
---
.dockerignore | 1 -
.gitignore | 3 +-
docker-compose-keycloak.yml | 11 +-
keycloak.env | 31 +-
keycloak/realm/realm-export.json | 371 ++----------------
.../resources/application-docker-keycloak.yml | 64 +++
.../application-docker-keycloak.yml-example | 90 ++---
7 files changed, 158 insertions(+), 413 deletions(-)
create mode 100644 src/main/resources/application-docker-keycloak.yml
diff --git a/.dockerignore b/.dockerignore
index a8b2ec0..dec9669 100644
--- a/.dockerignore
+++ b/.dockerignore
@@ -17,4 +17,3 @@ logs/
# Gitignored local config holding real credentials: must never reach an image.
.env
src/main/resources/application-local.yml
-src/main/resources/application-docker-keycloak.yml
diff --git a/.gitignore b/.gitignore
index 2162221..b0aff5b 100644
--- a/.gitignore
+++ b/.gitignore
@@ -135,7 +135,8 @@ application-local.yml
/project_concatenated.txt
.env
/repomix-output.txt
-src/main/resources/application-docker-keycloak.yml
+# src/main/resources/application-docker-keycloak.yml is tracked on purpose: it holds only ${ENV_VAR}
+# placeholders, and the Keycloak stack builds the app image from src/, so it must be in the repo.
# VS Code: personal settings stay local, shared tasks.json is tracked
.vscode/*
diff --git a/docker-compose-keycloak.yml b/docker-compose-keycloak.yml
index ddf6aa3..51c8f65 100644
--- a/docker-compose-keycloak.yml
+++ b/docker-compose-keycloak.yml
@@ -65,6 +65,10 @@ services:
condition: service_healthy
mailserver:
condition: service_healthy
+ # Not strictly required to boot, but the demo is useless until Keycloak answers, and this makes
+ # `up` report the stack as ready only when a Keycloak login can actually be attempted.
+ keycloak:
+ condition: service_healthy
ports:
- "8080:8080"
env_file: keycloak.env
@@ -106,7 +110,12 @@ services:
KC_DB_USERNAME: springuser
KC_DB_PASSWORD: springuser
healthcheck:
- test: ['CMD-SHELL', '[ -f /tmp/HealthCheck.java ] || echo "public class HealthCheck { public static void main(String[] args) throws java.lang.Throwable { System.exit(java.net.HttpURLConnection.HTTP_OK == ((java.net.HttpURLConnection)new java.net.URL(args[0]).openConnection()).getResponseCode() ? 0 : 1); } }" > /tmp/HealthCheck.java && java /tmp/HealthCheck.java http://localhost:8080/auth/health/live']
+ # The image has no curl or wget, so the probe is a one-file Java program.
+ # It probes the realm's OIDC metadata rather than /health/live: Keycloak 25 serves health on the
+ # management port only, and because KC_HTTPS_CERTIFICATE_FILE is set that port speaks HTTPS with
+ # a self-signed certificate this probe cannot verify. The metadata document also proves the demo
+ # realm finished importing, which /health/live does not.
+ test: ['CMD-SHELL', '[ -f /tmp/HealthCheck.java ] || echo "public class HealthCheck { public static void main(String[] args) throws java.lang.Throwable { System.exit(java.net.HttpURLConnection.HTTP_OK == ((java.net.HttpURLConnection)new java.net.URL(args[0]).openConnection()).getResponseCode() ? 0 : 1); } }" > /tmp/HealthCheck.java && java /tmp/HealthCheck.java http://localhost:8080/realms/demo/.well-known/openid-configuration']
interval: 5s
timeout: 5s
retries: 20
diff --git a/keycloak.env b/keycloak.env
index a585f0a..46c777e 100644
--- a/keycloak.env
+++ b/keycloak.env
@@ -1,17 +1,34 @@
-# Spring User Demo App
+# Shared by the app and the keycloak services in docker-compose-keycloak.yml.
+# Dev-only credentials. Do not reuse these anywhere real.
+
+# Spring User Demo App: OIDC client registration.
+# The client id and secret must match the client in keycloak/realm/realm-export.json.
DS_SPRING_USER_KEYCLOAK_CLIENT_ID=ds-spring-user-framework-demo
DS_SPRING_USER_KEYCLOAK_CLIENT_SECRET=FTp1j7sGvc4g3MFdghEX4n7RPhbu86PQ
-DS_SPRING_USER_KEYCLOAK_PROVIDER_ISSUER_URI=http://keycloak:8080/realms/master
-DS_SPRING_USER_KEYCLOAK_PROVIDER_AUTHORIZATION_URI=http://keycloak:8080/realms/master/protocol/openid-connect/auth
-DS_SPRING_USER_KEYCLOAK_PROVIDER_TOKEN_URI=http://keycloak:8080/realms/master/protocol/openid-connect/token
-DS_SPRING_USER_KEYCLOAK_PROVIDER_USER_INFO_URI=http://keycloak:8080/realms/master/protocol/openid-connect/userinfo
-DS_SPRING_USER_KEYCLOAK_PROVIDER_JWK_SET_URI=http://keycloak:8080/realms/master/protocol/openid-connect/certs
-# Keycloak
+# Two different hostnames on purpose.
+# The authorization endpoint is the only one a browser is sent to, so it must be an address that
+# resolves on the host: Keycloak is published there as localhost:8180.
+# The token, userinfo and JWK endpoints are called by the app container over the compose network,
+# where Keycloak answers as keycloak:8080.
+# There is deliberately no issuer-uri. See src/main/resources/application-docker-keycloak.yml.
+DS_SPRING_USER_KEYCLOAK_PROVIDER_AUTHORIZATION_URI=http://localhost:8180/realms/demo/protocol/openid-connect/auth
+DS_SPRING_USER_KEYCLOAK_PROVIDER_TOKEN_URI=http://keycloak:8080/realms/demo/protocol/openid-connect/token
+DS_SPRING_USER_KEYCLOAK_PROVIDER_USER_INFO_URI=http://keycloak:8080/realms/demo/protocol/openid-connect/userinfo
+DS_SPRING_USER_KEYCLOAK_PROVIDER_JWK_SET_URI=http://keycloak:8080/realms/demo/protocol/openid-connect/certs
+
+# Keycloak server
KC_DB=mariadb
KC_RUN_IN_CONTAINER=true
KC_HTTP_ENABLED=true
+# Hostname v2 (https://www.keycloak.org/server/hostname). KC_HOSTNAME is the frontend URL: the address
+# browsers use, and the value Keycloak puts in the token "iss" claim. It is the published port, 8180.
+# KC_HOSTNAME_BACKCHANNEL_DYNAMIC lets server-to-server callers reach Keycloak on the address they
+# actually called (keycloak:8080 on the compose network) instead of being sent back to the frontend URL.
+KC_HOSTNAME=http://localhost:8180
+KC_HOSTNAME_BACKCHANNEL_DYNAMIC=true
KC_HOSTNAME_STRICT=false
+# Serves /health/* and /metrics on the management port (9000 in the container, published as 9001).
KC_HEALTH_ENABLED=true
KC_METRICS_ENABLED=true
KC_HTTPS_CERTIFICATE_FILE=/opt/keycloak/ssl/certificate.pem
diff --git a/keycloak/realm/realm-export.json b/keycloak/realm/realm-export.json
index d93cd28..6b15b13 100644
--- a/keycloak/realm/realm-export.json
+++ b/keycloak/realm/realm-export.json
@@ -1,8 +1,8 @@
{
"id": "8d077044-05d5-4fae-8f9f-a7083279398f",
- "realm": "master",
- "displayName": "Keycloak",
- "displayNameHtml": "Keycloak
",
+ "realm": "demo",
+ "displayName": "Spring User Framework Demo",
+ "displayNameHtml": "Demo
",
"notBefore": 0,
"defaultSignatureAlgorithm": "RS256",
"revokeRefreshToken": false,
@@ -48,15 +48,6 @@
"failureFactor": 30,
"roles": {
"realm": [
- {
- "id": "79a8a081-f13a-404b-bd28-3ee5fe6ba794",
- "name": "create-realm",
- "description": "${role_create-realm}",
- "composite": false,
- "clientRole": false,
- "containerId": "8d077044-05d5-4fae-8f9f-a7083279398f",
- "attributes": {}
- },
{
"id": "147ddf4d-1cb8-4180-8605-dfb09e89cad3",
"name": "offline_access",
@@ -66,42 +57,6 @@
"containerId": "8d077044-05d5-4fae-8f9f-a7083279398f",
"attributes": {}
},
- {
- "id": "bcc1b14e-2ad7-41ec-91cd-cf4af946b798",
- "name": "admin",
- "description": "${role_admin}",
- "composite": true,
- "composites": {
- "realm": [
- "create-realm"
- ],
- "client": {
- "master-realm": [
- "manage-clients",
- "create-client",
- "view-realm",
- "impersonation",
- "view-identity-providers",
- "query-groups",
- "query-users",
- "manage-events",
- "view-authorization",
- "manage-identity-providers",
- "query-clients",
- "manage-authorization",
- "manage-users",
- "view-clients",
- "manage-realm",
- "query-realms",
- "view-users",
- "view-events"
- ]
- }
- },
- "clientRole": false,
- "containerId": "8d077044-05d5-4fae-8f9f-a7083279398f",
- "attributes": {}
- },
{
"id": "eb776c2f-051a-4c46-b4f6-723a4f700a27",
"name": "uma_authorization",
@@ -113,7 +68,7 @@
},
{
"id": "f4f10912-1a86-4af3-a983-dcc9bbe4ebb7",
- "name": "default-roles-master",
+ "name": "default-roles-demo",
"description": "${role_default-roles}",
"composite": true,
"composites": {
@@ -135,16 +90,6 @@
],
"client": {
"security-admin-console": [],
- "ds-spring-user-framework-demo": [
- {
- "id": "6ce5caf6-21e0-41f3-9262-b23fb71bdf6b",
- "name": "uma_protection",
- "composite": false,
- "clientRole": true,
- "containerId": "93c0e869-3ea1-4bf4-a7eb-55836b1dcdb3",
- "attributes": {}
- }
- ],
"admin-cli": [],
"account-console": [],
"broker": [
@@ -158,185 +103,6 @@
"attributes": {}
}
],
- "master-realm": [
- {
- "id": "89c4fb73-15c0-4dcd-b886-6d111fd8a0af",
- "name": "manage-identity-providers",
- "description": "${role_manage-identity-providers}",
- "composite": false,
- "clientRole": true,
- "containerId": "f8bfbc81-96e7-4c0a-bfb2-c5347cda99b6",
- "attributes": {}
- },
- {
- "id": "6b48a313-e505-4083-bd0f-9cf8a0b7800b",
- "name": "manage-users",
- "description": "${role_manage-users}",
- "composite": false,
- "clientRole": true,
- "containerId": "f8bfbc81-96e7-4c0a-bfb2-c5347cda99b6",
- "attributes": {}
- },
- {
- "id": "f0f48ac6-33bd-4890-bb9e-fbe94295bedb",
- "name": "view-clients",
- "description": "${role_view-clients}",
- "composite": true,
- "composites": {
- "client": {
- "master-realm": [
- "query-clients"
- ]
- }
- },
- "clientRole": true,
- "containerId": "f8bfbc81-96e7-4c0a-bfb2-c5347cda99b6",
- "attributes": {}
- },
- {
- "id": "08c38fb7-7f8f-4479-b6af-d9513b5d6ab1",
- "name": "manage-events",
- "description": "${role_manage-events}",
- "composite": false,
- "clientRole": true,
- "containerId": "f8bfbc81-96e7-4c0a-bfb2-c5347cda99b6",
- "attributes": {}
- },
- {
- "id": "2036ea4e-aa78-407a-ac64-ef4382ae8a9a",
- "name": "manage-realm",
- "description": "${role_manage-realm}",
- "composite": false,
- "clientRole": true,
- "containerId": "f8bfbc81-96e7-4c0a-bfb2-c5347cda99b6",
- "attributes": {}
- },
- {
- "id": "806cbffd-e121-410f-94f7-eecb104e4ebb",
- "name": "view-authorization",
- "description": "${role_view-authorization}",
- "composite": false,
- "clientRole": true,
- "containerId": "f8bfbc81-96e7-4c0a-bfb2-c5347cda99b6",
- "attributes": {}
- },
- {
- "id": "b4b2bd79-2ae7-4dc7-bbba-742bfd8fdc0f",
- "name": "manage-authorization",
- "description": "${role_manage-authorization}",
- "composite": false,
- "clientRole": true,
- "containerId": "f8bfbc81-96e7-4c0a-bfb2-c5347cda99b6",
- "attributes": {}
- },
- {
- "id": "f65d68ce-c26e-411d-b3ce-55840f6da0d8",
- "name": "manage-clients",
- "description": "${role_manage-clients}",
- "composite": false,
- "clientRole": true,
- "containerId": "f8bfbc81-96e7-4c0a-bfb2-c5347cda99b6",
- "attributes": {}
- },
- {
- "id": "63d0fa43-40c9-4a47-8cad-6e86ec12279a",
- "name": "query-clients",
- "description": "${role_query-clients}",
- "composite": false,
- "clientRole": true,
- "containerId": "f8bfbc81-96e7-4c0a-bfb2-c5347cda99b6",
- "attributes": {}
- },
- {
- "id": "8cc30afb-d5ee-4560-b9ef-269d77ce079a",
- "name": "create-client",
- "description": "${role_create-client}",
- "composite": false,
- "clientRole": true,
- "containerId": "f8bfbc81-96e7-4c0a-bfb2-c5347cda99b6",
- "attributes": {}
- },
- {
- "id": "ffdb068a-cf8c-4c5d-9b45-1a27b83b64b8",
- "name": "query-realms",
- "description": "${role_query-realms}",
- "composite": false,
- "clientRole": true,
- "containerId": "f8bfbc81-96e7-4c0a-bfb2-c5347cda99b6",
- "attributes": {}
- },
- {
- "id": "42b4e4fc-c7a2-418b-8eaa-18747eaf2491",
- "name": "view-realm",
- "description": "${role_view-realm}",
- "composite": false,
- "clientRole": true,
- "containerId": "f8bfbc81-96e7-4c0a-bfb2-c5347cda99b6",
- "attributes": {}
- },
- {
- "id": "b73c509e-e2ba-420b-9adc-d647a33611a6",
- "name": "impersonation",
- "description": "${role_impersonation}",
- "composite": false,
- "clientRole": true,
- "containerId": "f8bfbc81-96e7-4c0a-bfb2-c5347cda99b6",
- "attributes": {}
- },
- {
- "id": "59c168c3-4204-42e3-9575-d978430b980b",
- "name": "view-users",
- "description": "${role_view-users}",
- "composite": true,
- "composites": {
- "client": {
- "master-realm": [
- "query-groups",
- "query-users"
- ]
- }
- },
- "clientRole": true,
- "containerId": "f8bfbc81-96e7-4c0a-bfb2-c5347cda99b6",
- "attributes": {}
- },
- {
- "id": "bf2164f0-2bf8-4516-b36a-20b5f502f6b9",
- "name": "view-events",
- "description": "${role_view-events}",
- "composite": false,
- "clientRole": true,
- "containerId": "f8bfbc81-96e7-4c0a-bfb2-c5347cda99b6",
- "attributes": {}
- },
- {
- "id": "5ff312b4-534e-44b3-9eff-5f43db54a783",
- "name": "view-identity-providers",
- "description": "${role_view-identity-providers}",
- "composite": false,
- "clientRole": true,
- "containerId": "f8bfbc81-96e7-4c0a-bfb2-c5347cda99b6",
- "attributes": {}
- },
- {
- "id": "b89aae4d-70ad-4758-a41a-a6314babacff",
- "name": "query-groups",
- "description": "${role_query-groups}",
- "composite": false,
- "clientRole": true,
- "containerId": "f8bfbc81-96e7-4c0a-bfb2-c5347cda99b6",
- "attributes": {}
- },
- {
- "id": "2f4972a7-ce56-438e-a001-0c3736938c5e",
- "name": "query-users",
- "description": "${role_query-users}",
- "composite": false,
- "clientRole": true,
- "containerId": "f8bfbc81-96e7-4c0a-bfb2-c5347cda99b6",
- "attributes": {}
- }
- ],
"account": [
{
"id": "15de0cfa-5109-46db-abb7-304ea139a8eb",
@@ -430,7 +196,7 @@
"groups": [],
"defaultRole": {
"id": "f4f10912-1a86-4af3-a983-dcc9bbe4ebb7",
- "name": "default-roles-master",
+ "name": "default-roles-demo",
"description": "${role_default-roles}",
"composite": true,
"clientRole": false,
@@ -480,24 +246,23 @@
"webAuthnPolicyPasswordlessExtraOrigins": [],
"users": [
{
- "id": "17e5d064-0461-46b9-b3e1-3cd843c4e5ac",
- "username": "service-account-ds-spring-user-framework-demo",
- "emailVerified": false,
- "createdTimestamp": 1738208958694,
+ "username": "demo",
+ "email": "demo@example.com",
+ "emailVerified": true,
+ "firstName": "Demo",
+ "lastName": "User",
"enabled": true,
- "totp": false,
- "serviceAccountClientId": "ds-spring-user-framework-demo",
- "disableableCredentialTypes": [],
+ "credentials": [
+ {
+ "type": "password",
+ "value": "demo",
+ "temporary": false
+ }
+ ],
"requiredActions": [],
"realmRoles": [
- "default-roles-master"
+ "default-roles-demo"
],
- "clientRoles": {
- "ds-spring-user-framework-demo": [
- "uma_protection"
- ]
- },
- "notBefore": 0,
"groups": []
}
],
@@ -526,13 +291,13 @@
"clientId": "account",
"name": "${client_account}",
"rootUrl": "${authBaseUrl}",
- "baseUrl": "/realms/master/account/",
+ "baseUrl": "/realms/demo/account/",
"surrogateAuthRequired": false,
"enabled": true,
"alwaysDisplayInConsole": false,
"clientAuthenticatorType": "client-secret",
"redirectUris": [
- "/realms/master/account/*"
+ "/realms/demo/account/*"
],
"webOrigins": [],
"notBefore": 0,
@@ -571,13 +336,13 @@
"clientId": "account-console",
"name": "${client_account-console}",
"rootUrl": "${authBaseUrl}",
- "baseUrl": "/realms/master/account/",
+ "baseUrl": "/realms/demo/account/",
"surrogateAuthRequired": false,
"enabled": true,
"alwaysDisplayInConsole": false,
"clientAuthenticatorType": "client-secret",
"redirectUris": [
- "/realms/master/account/*"
+ "/realms/demo/account/*"
],
"webOrigins": [],
"notBefore": 0,
@@ -712,12 +477,12 @@
"enabled": true,
"alwaysDisplayInConsole": true,
"clientAuthenticatorType": "client-secret",
- "secret": "**********",
+ "secret": "FTp1j7sGvc4g3MFdghEX4n7RPhbu86PQ",
"redirectUris": [
"*"
],
"webOrigins": [
- "http://0.0.0.0:8080"
+ "http://localhost:8080"
],
"notBefore": 0,
"bearerOnly": false,
@@ -725,8 +490,8 @@
"standardFlowEnabled": true,
"implicitFlowEnabled": false,
"directAccessGrantsEnabled": false,
- "serviceAccountsEnabled": true,
- "authorizationServicesEnabled": true,
+ "serviceAccountsEnabled": false,
+ "authorizationServicesEnabled": false,
"publicClient": false,
"frontchannelLogout": false,
"protocol": "openid-connect",
@@ -824,84 +589,6 @@
"basic",
"email"
],
- "optionalClientScopes": [
- "address",
- "phone",
- "offline_access",
- "microprofile-jwt"
- ],
- "authorizationSettings": {
- "allowRemoteResourceManagement": true,
- "policyEnforcementMode": "ENFORCING",
- "resources": [
- {
- "name": "Default Resource",
- "type": "urn:ds-spring-user-framework-demo:resources:default",
- "ownerManagedAccess": false,
- "attributes": {},
- "uris": [
- "/*"
- ]
- }
- ],
- "policies": [
- {
- "name": "Default Policy",
- "description": "A policy that grants access only for users within this realm",
- "type": "js",
- "logic": "POSITIVE",
- "decisionStrategy": "AFFIRMATIVE",
- "config": {
- "code": "// by default, grants any permission associated with this policy\n$evaluation.grant();\n"
- }
- },
- {
- "name": "Default Permission",
- "description": "A permission that applies to the default resource type",
- "type": "resource",
- "logic": "POSITIVE",
- "decisionStrategy": "UNANIMOUS",
- "config": {
- "defaultResourceType": "urn:ds-spring-user-framework-demo:resources:default",
- "applyPolicies": "[\"Default Policy\"]"
- }
- }
- ],
- "scopes": [],
- "decisionStrategy": "UNANIMOUS"
- }
- },
- {
- "id": "f8bfbc81-96e7-4c0a-bfb2-c5347cda99b6",
- "clientId": "master-realm",
- "name": "master Realm",
- "surrogateAuthRequired": false,
- "enabled": true,
- "alwaysDisplayInConsole": false,
- "clientAuthenticatorType": "client-secret",
- "redirectUris": [],
- "webOrigins": [],
- "notBefore": 0,
- "bearerOnly": true,
- "consentRequired": false,
- "standardFlowEnabled": true,
- "implicitFlowEnabled": false,
- "directAccessGrantsEnabled": false,
- "serviceAccountsEnabled": false,
- "publicClient": false,
- "frontchannelLogout": false,
- "attributes": {},
- "authenticationFlowBindingOverrides": {},
- "fullScopeAllowed": false,
- "nodeReRegistrationTimeout": 0,
- "defaultClientScopes": [
- "web-origins",
- "acr",
- "profile",
- "roles",
- "basic",
- "email"
- ],
"optionalClientScopes": [
"address",
"phone",
@@ -914,13 +601,13 @@
"clientId": "security-admin-console",
"name": "${client_security-admin-console}",
"rootUrl": "${authAdminUrl}",
- "baseUrl": "/admin/master/console/",
+ "baseUrl": "/admin/demo/console/",
"surrogateAuthRequired": false,
"enabled": true,
"alwaysDisplayInConsole": false,
"clientAuthenticatorType": "client-secret",
"redirectUris": [
- "/admin/master/console/*"
+ "/admin/demo/console/*"
],
"webOrigins": [
"+"
@@ -2444,4 +2131,4 @@
"clientPolicies": {
"policies": []
}
-}
\ No newline at end of file
+}
diff --git a/src/main/resources/application-docker-keycloak.yml b/src/main/resources/application-docker-keycloak.yml
new file mode 100644
index 0000000..b0a8967
--- /dev/null
+++ b/src/main/resources/application-docker-keycloak.yml
@@ -0,0 +1,64 @@
+# Profile used by docker-compose-keycloak.yml (SPRING_PROFILES_ACTIVE=docker-keycloak).
+# It only adds the Keycloak OIDC client on top of application.yml. Every value comes from an
+# environment variable set in keycloak.env, so this file holds no credentials and is tracked in git;
+# the app image is built from src/, so it has to be tracked for `docker compose up --build` to work
+# on a fresh clone.
+
+logging:
+ level:
+ com:
+ digitalsanctuary: DEBUG # Framework and demo logging. Add org.springframework.security: DEBUG to trace the OIDC flow.
+
+spring:
+ security:
+ oauth2:
+ enabled: true # Enable or disable OAuth2
+ client:
+ registration:
+ keycloak:
+ client-id: ${DS_SPRING_USER_KEYCLOAK_CLIENT_ID} # Keycloak client ID for OAuth2
+ client-secret: ${DS_SPRING_USER_KEYCLOAK_CLIENT_SECRET} # Keycloak client secret for OAuth2
+ authorization-grant-type: authorization_code # Authorization grant type for OAuth2
+ # Spring Boot only fills in a default redirect-uri when the provider is a well-known one or
+ # is discovered from an issuer-uri, and neither applies here (see the note below), so it is
+ # spelled out. {baseUrl} expands to http://localhost:8080 for a browser on the host.
+ redirect-uri: "{baseUrl}/login/oauth2/code/{registrationId}"
+ scope:
+ - email # Request email scope for OAuth2
+ - profile # Request profile scope for OAuth2
+ - openid # Request oidc scope for OAuth2
+ client-name: Keycloak # Name of the OAuth2 client
+ provider: keycloak
+ provider:
+ keycloak: # https://www.keycloak.org/securing-apps/oidc-layers
+ # Browser-facing. Must resolve in the host browser, so it uses the published port 8180.
+ authorization-uri: ${DS_SPRING_USER_KEYCLOAK_PROVIDER_AUTHORIZATION_URI}
+ # No issuer-uri on purpose, and this is the one thing this stack gives up.
+ # Keycloak stamps the ID token "iss" with its frontend URL, http://localhost:8180/realms/demo.
+ # Spring Boot treats issuer-uri as a discovery location: it fetches
+ # /.well-known/openid-configuration at startup and fails if the document's issuer
+ # differs. The app container cannot reach localhost:8180 (that is the host's published port),
+ # and the address it can reach, keycloak:8080, is not the issuer. There is no single URL that
+ # works from both a host browser and a container without editing /etc/hosts, so the property is
+ # left unset. Spring Security then skips the "iss" comparison (OidcIdTokenValidator only
+ # compares when the client registration has an issuer). Signature, audience, nonce and
+ # expiry are still validated. With real DNS in front of Keycloak, set issuer-uri to the
+ # public issuer and drop the split below.
+ # Server-to-server. These are called by the app container over the compose network.
+ token-uri: ${DS_SPRING_USER_KEYCLOAK_PROVIDER_TOKEN_URI}
+ user-info-uri: ${DS_SPRING_USER_KEYCLOAK_PROVIDER_USER_INFO_URI}
+ jwk-set-uri: ${DS_SPRING_USER_KEYCLOAK_PROVIDER_JWK_SET_URI}
+ user-name-attribute: preferred_username # https://www.keycloak.org/docs-api/latest/rest-api/index.html#UserRepresentation
+
+server:
+ port: 8080
+ servlet:
+ session:
+ cookie:
+ # The stack is plain HTTP. A secure cookie would not be sent back on the OAuth2 redirect, so the
+ # saved authorization request would be lost and the callback would fail.
+ secure: false
+
+user:
+ registration:
+ keycloakEnabled: true # Shows the "Login with Keycloak" button on /user/login.html
diff --git a/src/main/resources/application-docker-keycloak.yml-example b/src/main/resources/application-docker-keycloak.yml-example
index f69a70d..b0a8967 100644
--- a/src/main/resources/application-docker-keycloak.yml-example
+++ b/src/main/resources/application-docker-keycloak.yml-example
@@ -1,25 +1,15 @@
-debug: true # Enable or disable debug mode
+# Profile used by docker-compose-keycloak.yml (SPRING_PROFILES_ACTIVE=docker-keycloak).
+# It only adds the Keycloak OIDC client on top of application.yml. Every value comes from an
+# environment variable set in keycloak.env, so this file holds no credentials and is tracked in git;
+# the app image is built from src/, so it has to be tracked for `docker compose up --build` to work
+# on a fresh clone.
logging:
level:
com:
- digitalsanctuary: DEBUG # Set logging level for digitalsanctuary package
- org:
- springframework:
- web:
- filter:
- CommonsRequestLoggingFilter: DEBUG # Set logging level for CommonsRequestLoggingFilter
- boot:
- autoconfigure:
- logging: INFO # suppress condition report
- security: DEBUG # Set logging level for security
+ digitalsanctuary: DEBUG # Framework and demo logging. Add org.springframework.security: DEBUG to trace the OIDC flow.
spring:
- application:
- name: Spring User Framework Demo App
- datasource:
- driverClassName: org.mariadb.jdbc.Driver
- # mail: # Mail configuration is managed externally by Spring native environment variables
security:
oauth2:
enabled: true # Enable or disable OAuth2
@@ -29,6 +19,10 @@ spring:
client-id: ${DS_SPRING_USER_KEYCLOAK_CLIENT_ID} # Keycloak client ID for OAuth2
client-secret: ${DS_SPRING_USER_KEYCLOAK_CLIENT_SECRET} # Keycloak client secret for OAuth2
authorization-grant-type: authorization_code # Authorization grant type for OAuth2
+ # Spring Boot only fills in a default redirect-uri when the provider is a well-known one or
+ # is discovered from an issuer-uri, and neither applies here (see the note below), so it is
+ # spelled out. {baseUrl} expands to http://localhost:8080 for a browser on the host.
+ redirect-uri: "{baseUrl}/login/oauth2/code/{registrationId}"
scope:
- email # Request email scope for OAuth2
- profile # Request profile scope for OAuth2
@@ -37,60 +31,34 @@ spring:
provider: keycloak
provider:
keycloak: # https://www.keycloak.org/securing-apps/oidc-layers
- issuer-uri: ${DS_SPRING_USER_KEYCLOAK_PROVIDER_ISSUER_URI}
+ # Browser-facing. Must resolve in the host browser, so it uses the published port 8180.
authorization-uri: ${DS_SPRING_USER_KEYCLOAK_PROVIDER_AUTHORIZATION_URI}
+ # No issuer-uri on purpose, and this is the one thing this stack gives up.
+ # Keycloak stamps the ID token "iss" with its frontend URL, http://localhost:8180/realms/demo.
+ # Spring Boot treats issuer-uri as a discovery location: it fetches
+ # /.well-known/openid-configuration at startup and fails if the document's issuer
+ # differs. The app container cannot reach localhost:8180 (that is the host's published port),
+ # and the address it can reach, keycloak:8080, is not the issuer. There is no single URL that
+ # works from both a host browser and a container without editing /etc/hosts, so the property is
+ # left unset. Spring Security then skips the "iss" comparison (OidcIdTokenValidator only
+ # compares when the client registration has an issuer). Signature, audience, nonce and
+ # expiry are still validated. With real DNS in front of Keycloak, set issuer-uri to the
+ # public issuer and drop the split below.
+ # Server-to-server. These are called by the app container over the compose network.
token-uri: ${DS_SPRING_USER_KEYCLOAK_PROVIDER_TOKEN_URI}
user-info-uri: ${DS_SPRING_USER_KEYCLOAK_PROVIDER_USER_INFO_URI}
- user-name-attribute: preferred_username # https://www.keycloak.org/docs-api/latest/rest-api/index.html#UserRepresentation
jwk-set-uri: ${DS_SPRING_USER_KEYCLOAK_PROVIDER_JWK_SET_URI}
- thymeleaf:
- cache: 'false' # Enable or disable Thymeleaf cache
- prefix: file:src/main/resources/templates/ # Prefix for Thymeleaf templates
- devtools:
- restart:
- enabled: 'true' # Enable or disable devtools restart
- poll-interval: '2s' # Poll interval for devtools restart
- quiet-period: '1s' # Quiet period for devtools restart
-
- additional-paths:
- - src/main/java/ # Additional paths for devtools restart
-
- livereload:
- enabled: 'true' # Enable or disable livereload
- https: 'true' # Enable or disable HTTPS for livereload
-
- mvc:
- log-request-details: 'true' # Enable or disable request details logging
- web: # Web configuration
- resources:
- static-locations: file:src/main/resources/static/, classpath:/static/
- cache:
- period: 0
+ user-name-attribute: preferred_username # https://www.keycloak.org/docs-api/latest/rest-api/index.html#UserRepresentation
server:
port: 8080
servlet:
session:
cookie:
- secure: false # disabling secure cookie for local development
+ # The stack is plain HTTP. A secure cookie would not be sent back on the OAuth2 redirect, so the
+ # saved authorization request would be lost and the callback would fail.
+ secure: false
user:
- audit:
- flushOnWrite: true # Enable flush on write for user audit
- logFilePath: user-audit.log
- registration: # User registration configuration
- sendVerificationEmail: true # Disable sending verification email
- googleEnabled: false # Enable Google registration
- facebookEnabled: false # Enable Facebook registration
- keycloakEnabled: true # Enable Keycloak registration
- security:
- unprotectedURIs: /,/index.html,/favicon.ico,/css/*,/js/*,/js/user/*,/js/event/*,/img/*,/user/registration,/user/resendRegistrationToken,/user/resetPassword,/user/registrationConfirm,/user/changePassword,/user/savePassword,/oauth2/authorization/*,/login,/user/login,/user/login.html,/swagger-ui.html,/swagger-ui/**,/v3/api-docs/**
- mail:
- fromAddress: anirban.das@t-online.de # From address for outbound mail
-
-management:
- newrelic:
- metrics:
- export:
- account-id: ACCTID # Account ID for New Relic metrics export
- api-key: KEYYYYY # API key for New Relic metrics export
+ registration:
+ keycloakEnabled: true # Shows the "Login with Keycloak" button on /user/login.html
From d85b4d19d93889ea69b7344efc403498cab4d12b Mon Sep 17 00:00:00 2001
From: Devon Hillard
Date: Sat, 15 Aug 2026 01:12:31 -0600
Subject: [PATCH 14/26] docs: describe the keycloak stack in keycloak/README.md
---
keycloak/README.md | 60 ++++++++++++++++++++++++++++++++++++++++++++++
1 file changed, 60 insertions(+)
create mode 100644 keycloak/README.md
diff --git a/keycloak/README.md b/keycloak/README.md
new file mode 100644
index 0000000..7936a91
--- /dev/null
+++ b/keycloak/README.md
@@ -0,0 +1,60 @@
+# Keycloak stack
+
+`docker-compose-keycloak.yml` runs the demo app against a Keycloak OIDC provider alongside the
+built-in form login. Four containers: the app, Keycloak 25.0.6, one MariaDB shared by both, and a
+mail server. The image is built from source inside Docker, so a fresh clone needs no local Gradle
+build. `up` returns when Keycloak is answering and the app is healthy, about a minute cold.
+
+```bash
+docker compose -f docker-compose-keycloak.yml up -d --build
+docker compose -f docker-compose-keycloak.yml down -v # stop and delete the data
+```
+
+## Ports and credentials
+
+| What | URL | Login |
+| --- | --- | --- |
+| Demo app | http://localhost:8080 | see below |
+| Keycloak | http://localhost:8180 | `admin` / `admin` (master realm) |
+| Keycloak HTTPS | https://localhost:8143 | self-signed, see `ssl/README.MD` |
+| Keycloak management | port 9001 (container 9000) | HTTPS, serves `/health/*` and `/metrics` |
+| MariaDB | localhost:3307 | `springuser` / `springuser` |
+
+All of these are dev-only credentials committed to the repository. Do not reuse them.
+
+## Log in through Keycloak
+
+1. Open http://localhost:8080/user/login.html
+2. Click "Login with Keycloak". The browser goes to Keycloak at http://localhost:8180.
+3. Sign in as `demo` / `demo`.
+4. You land back on http://localhost:8080/index.html?messageKey=message.login.success, signed in as
+ Demo User. The app creates a local account for the Keycloak identity on first login
+ (provider `KEYCLOAK`, email `demo@example.com`).
+
+`admin` / `admin` is a master realm account for the admin console only, not a demo realm user.
+
+## The realm
+
+`realm/realm-export.json` is imported by `--import-realm` on first start. It defines the realm `demo`,
+the confidential client `ds-spring-user-framework-demo`, and the `demo` user. The realm must not be
+`master`: Keycloak creates the master realm before the import runs and the import strategy is
+IGNORE_EXISTING, so a `master` export is skipped without an error.
+
+To change the realm, edit it in the admin console, then export it back over the file. The management
+port has to be moved because the running server already holds 9000:
+
+```bash
+docker exec keycloak.openid-provider /opt/keycloak/bin/kc.sh export \
+ --realm demo --file /tmp/realm-export.json --http-management-port 9002
+docker cp keycloak.openid-provider:/tmp/realm-export.json keycloak/realm/realm-export.json
+```
+
+This includes users and the real client secret. The admin console's own partial export writes the
+secret as `**********` instead, which then no longer matches
+`DS_SPRING_USER_KEYCLOAK_CLIENT_SECRET` in `keycloak.env` and breaks the login.
+
+## Two hostnames
+
+Keycloak is one server on two addresses: `localhost:8180` for the host browser, `keycloak:8080` for
+the app container. `keycloak.env` splits the OIDC endpoints along that line, and
+`src/main/resources/application-docker-keycloak.yml` explains why there is no `issuer-uri`.
From abff2870bdeb865d52dded7061fea86b5623efb9 Mon Sep 17 00:00:00 2001
From: Devon Hillard
Date: Sat, 15 Aug 2026 01:25:00 -0600
Subject: [PATCH 15/26] fix: tighten the keycloak stack redirect uri, health
gate and readme
Review round 1.
- keycloak/README.md documented `up -d --build` and claimed it returns when
the stack is ready. It does not: nothing depends on myapp-main, so `up -d`
returns while the app is still booting, and the "about a minute" figure
ignored the in-image Gradle build on a fresh clone. Documents
`up -d --build --wait` and says the first build takes several minutes.
- The keycloak healthcheck had no start_period, so the depends_on gate added
in the previous commit gave Liquibase plus the realm import only the
interval*retries budget (~100s) before failing `up` outright. Added
start_period: 60s.
- Narrowed the demo client's redirectUris from ["*"] to the exact callback
http://localhost:8080/login/oauth2/code/keycloak, matching the redirect-uri
pinned in application-docker-keycloak.yml and the earlier webOrigins change.
- Deleted application-docker-keycloak.yml-example: byte-identical to the
tracked application-docker-keycloak.yml, so it could only drift.
- Labelled KC_HOSTNAME_STRICT vestigial: it only governs deriving the hostname
from request headers when KC_HOSTNAME is unset.
Checked with `docker compose -f docker-compose-keycloak.yml config` and
`python3 -m json.tool` on the realm export; the stack was not restarted.
---
docker-compose-keycloak.yml | 4 ++
keycloak.env | 3 +
keycloak/README.md | 33 +++++-----
keycloak/realm/realm-export.json | 2 +-
.../application-docker-keycloak.yml-example | 64 -------------------
5 files changed, 24 insertions(+), 82 deletions(-)
delete mode 100644 src/main/resources/application-docker-keycloak.yml-example
diff --git a/docker-compose-keycloak.yml b/docker-compose-keycloak.yml
index 51c8f65..61d9efe 100644
--- a/docker-compose-keycloak.yml
+++ b/docker-compose-keycloak.yml
@@ -119,6 +119,10 @@ services:
interval: 5s
timeout: 5s
retries: 20
+ # myapp-main gates on this probe, so a slow first start must not fail `up`. Failures inside the
+ # start period do not count against retries: Liquibase schema creation plus the realm import
+ # takes about 30s here and more on a cold or loaded machine.
+ start_period: 60s
depends_on:
myapp-db:
condition: service_healthy
diff --git a/keycloak.env b/keycloak.env
index 46c777e..c17bf11 100644
--- a/keycloak.env
+++ b/keycloak.env
@@ -27,6 +27,9 @@ KC_HTTP_ENABLED=true
# actually called (keycloak:8080 on the compose network) instead of being sent back to the frontend URL.
KC_HOSTNAME=http://localhost:8180
KC_HOSTNAME_BACKCHANNEL_DYNAMIC=true
+# Vestigial: KC_HOSTNAME_STRICT only governs deriving the hostname from request headers when
+# KC_HOSTNAME is unset, so with a full URL above it changes nothing. Kept because it is what the
+# older Keycloak guides tell you to set, and removing it invites someone to add it back.
KC_HOSTNAME_STRICT=false
# Serves /health/* and /metrics on the management port (9000 in the container, published as 9001).
KC_HEALTH_ENABLED=true
diff --git a/keycloak/README.md b/keycloak/README.md
index 7936a91..5e5c115 100644
--- a/keycloak/README.md
+++ b/keycloak/README.md
@@ -2,11 +2,12 @@
`docker-compose-keycloak.yml` runs the demo app against a Keycloak OIDC provider alongside the
built-in form login. Four containers: the app, Keycloak 25.0.6, one MariaDB shared by both, and a
-mail server. The image is built from source inside Docker, so a fresh clone needs no local Gradle
-build. `up` returns when Keycloak is answering and the app is healthy, about a minute cold.
+mail server. The image builds from source in Docker (no local Gradle build), but that first build
+resolves dependencies and runs `bootJar` in the image: several minutes. Later starts are under a
+minute. `--wait` holds until every container is healthy; plain `up -d` returns mid-boot.
```bash
-docker compose -f docker-compose-keycloak.yml up -d --build
+docker compose -f docker-compose-keycloak.yml up -d --build --wait
docker compose -f docker-compose-keycloak.yml down -v # stop and delete the data
```
@@ -24,24 +25,22 @@ All of these are dev-only credentials committed to the repository. Do not reuse
## Log in through Keycloak
-1. Open http://localhost:8080/user/login.html
-2. Click "Login with Keycloak". The browser goes to Keycloak at http://localhost:8180.
-3. Sign in as `demo` / `demo`.
-4. You land back on http://localhost:8080/index.html?messageKey=message.login.success, signed in as
- Demo User. The app creates a local account for the Keycloak identity on first login
- (provider `KEYCLOAK`, email `demo@example.com`).
+1. Open http://localhost:8080/user/login.html and click "Login with Keycloak".
+2. Sign in at Keycloak (http://localhost:8180) as `demo` / `demo`.
+3. You land back on http://localhost:8080/index.html?messageKey=message.login.success, signed in as
+ Demo User. First login creates the local account (provider `KEYCLOAK`, `demo@example.com`).
`admin` / `admin` is a master realm account for the admin console only, not a demo realm user.
## The realm
-`realm/realm-export.json` is imported by `--import-realm` on first start. It defines the realm `demo`,
-the confidential client `ds-spring-user-framework-demo`, and the `demo` user. The realm must not be
-`master`: Keycloak creates the master realm before the import runs and the import strategy is
-IGNORE_EXISTING, so a `master` export is skipped without an error.
+`realm/realm-export.json` is imported by `--import-realm` on first start: realm `demo`, the client
+`ds-spring-user-framework-demo` (callback `http://localhost:8080/login/oauth2/code/keycloak`), and
+the `demo` user. The realm must not be `master`: Keycloak creates that realm before the import runs
+with strategy IGNORE_EXISTING, so a `master` export is skipped without an error.
To change the realm, edit it in the admin console, then export it back over the file. The management
-port has to be moved because the running server already holds 9000:
+port has to be moved, the running server holds 9000:
```bash
docker exec keycloak.openid-provider /opt/keycloak/bin/kc.sh export \
@@ -50,11 +49,11 @@ docker cp keycloak.openid-provider:/tmp/realm-export.json keycloak/realm/realm-e
```
This includes users and the real client secret. The admin console's own partial export writes the
-secret as `**********` instead, which then no longer matches
-`DS_SPRING_USER_KEYCLOAK_CLIENT_SECRET` in `keycloak.env` and breaks the login.
+secret as `**********`, which then stops matching `DS_SPRING_USER_KEYCLOAK_CLIENT_SECRET` in
+`keycloak.env` and breaks the login.
## Two hostnames
Keycloak is one server on two addresses: `localhost:8180` for the host browser, `keycloak:8080` for
the app container. `keycloak.env` splits the OIDC endpoints along that line, and
-`src/main/resources/application-docker-keycloak.yml` explains why there is no `issuer-uri`.
+`src/main/resources/application-docker-keycloak.yml` says why there is no `issuer-uri`.
diff --git a/keycloak/realm/realm-export.json b/keycloak/realm/realm-export.json
index 6b15b13..a817679 100644
--- a/keycloak/realm/realm-export.json
+++ b/keycloak/realm/realm-export.json
@@ -479,7 +479,7 @@
"clientAuthenticatorType": "client-secret",
"secret": "FTp1j7sGvc4g3MFdghEX4n7RPhbu86PQ",
"redirectUris": [
- "*"
+ "http://localhost:8080/login/oauth2/code/keycloak"
],
"webOrigins": [
"http://localhost:8080"
diff --git a/src/main/resources/application-docker-keycloak.yml-example b/src/main/resources/application-docker-keycloak.yml-example
deleted file mode 100644
index b0a8967..0000000
--- a/src/main/resources/application-docker-keycloak.yml-example
+++ /dev/null
@@ -1,64 +0,0 @@
-# Profile used by docker-compose-keycloak.yml (SPRING_PROFILES_ACTIVE=docker-keycloak).
-# It only adds the Keycloak OIDC client on top of application.yml. Every value comes from an
-# environment variable set in keycloak.env, so this file holds no credentials and is tracked in git;
-# the app image is built from src/, so it has to be tracked for `docker compose up --build` to work
-# on a fresh clone.
-
-logging:
- level:
- com:
- digitalsanctuary: DEBUG # Framework and demo logging. Add org.springframework.security: DEBUG to trace the OIDC flow.
-
-spring:
- security:
- oauth2:
- enabled: true # Enable or disable OAuth2
- client:
- registration:
- keycloak:
- client-id: ${DS_SPRING_USER_KEYCLOAK_CLIENT_ID} # Keycloak client ID for OAuth2
- client-secret: ${DS_SPRING_USER_KEYCLOAK_CLIENT_SECRET} # Keycloak client secret for OAuth2
- authorization-grant-type: authorization_code # Authorization grant type for OAuth2
- # Spring Boot only fills in a default redirect-uri when the provider is a well-known one or
- # is discovered from an issuer-uri, and neither applies here (see the note below), so it is
- # spelled out. {baseUrl} expands to http://localhost:8080 for a browser on the host.
- redirect-uri: "{baseUrl}/login/oauth2/code/{registrationId}"
- scope:
- - email # Request email scope for OAuth2
- - profile # Request profile scope for OAuth2
- - openid # Request oidc scope for OAuth2
- client-name: Keycloak # Name of the OAuth2 client
- provider: keycloak
- provider:
- keycloak: # https://www.keycloak.org/securing-apps/oidc-layers
- # Browser-facing. Must resolve in the host browser, so it uses the published port 8180.
- authorization-uri: ${DS_SPRING_USER_KEYCLOAK_PROVIDER_AUTHORIZATION_URI}
- # No issuer-uri on purpose, and this is the one thing this stack gives up.
- # Keycloak stamps the ID token "iss" with its frontend URL, http://localhost:8180/realms/demo.
- # Spring Boot treats issuer-uri as a discovery location: it fetches
- # /.well-known/openid-configuration at startup and fails if the document's issuer
- # differs. The app container cannot reach localhost:8180 (that is the host's published port),
- # and the address it can reach, keycloak:8080, is not the issuer. There is no single URL that
- # works from both a host browser and a container without editing /etc/hosts, so the property is
- # left unset. Spring Security then skips the "iss" comparison (OidcIdTokenValidator only
- # compares when the client registration has an issuer). Signature, audience, nonce and
- # expiry are still validated. With real DNS in front of Keycloak, set issuer-uri to the
- # public issuer and drop the split below.
- # Server-to-server. These are called by the app container over the compose network.
- token-uri: ${DS_SPRING_USER_KEYCLOAK_PROVIDER_TOKEN_URI}
- user-info-uri: ${DS_SPRING_USER_KEYCLOAK_PROVIDER_USER_INFO_URI}
- jwk-set-uri: ${DS_SPRING_USER_KEYCLOAK_PROVIDER_JWK_SET_URI}
- user-name-attribute: preferred_username # https://www.keycloak.org/docs-api/latest/rest-api/index.html#UserRepresentation
-
-server:
- port: 8080
- servlet:
- session:
- cookie:
- # The stack is plain HTTP. A secure cookie would not be sent back on the OAuth2 redirect, so the
- # saved authorization request would be lost and the callback would fail.
- secure: false
-
-user:
- registration:
- keycloakEnabled: true # Shows the "Login with Keycloak" button on /user/login.html
From a35847fcf116e1b6f849c5e9f5107c3c4d4cc5c3 Mon Sep 17 00:00:00 2001
From: Devon Hillard
Date: Sat, 15 Aug 2026 01:29:02 -0600
Subject: [PATCH 16/26] docs: reconcile keycloak profile docs with the tracked
yml and realm rename
---
docs/CONFIGURATION.md | 4 ++--
docs/DEVELOPMENT.md | 9 ++++++---
2 files changed, 8 insertions(+), 5 deletions(-)
diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md
index b4ac89c..0927787 100644
--- a/docs/CONFIGURATION.md
+++ b/docs/CONFIGURATION.md
@@ -30,7 +30,7 @@ If you omit `--args` entirely, `bootRun` still defaults to `local`: `build.gradl
| `prd` | [`application-prd.yml`](../src/main/resources/application-prd.yml) | Production | Thymeleaf caching on, `ddl-auto: validate`, env-driven datasource, strict/secure cookies, `WARN` logging, limited actuator exposure, env-driven WebAuthn RP identity and `appUrl`, `requireCanonicalAppUrl: true`, no fallback for the remember-me key | `--spring.profiles.active=prd` |
| `test` | [`src/test/resources/application-test.properties`](../src/test/resources/application-test.properties) | Automated JUnit suite | Per-context isolated H2 database, MFA off, test-only `unprotectedURIs`. It also sets `maxFailedLoginAttempts`/`lockoutDurationMinutes` (lines 16-17), but those aren't the framework's property names (`failedLoginAttempts`/`accountLockoutDuration`), so they don't bind; lockout stays at the inherited default (10 attempts / 30 min) | Applied automatically by `./gradlew test` |
| `playwright-test` | [`application-playwright-test.yml`](../src/main/resources/application-playwright-test.yml) | Playwright E2E runs; enables the Test API (`TestDataController`, `TestApiSecurityConfig`, localhost-only) | Disables verification/reset emails, pins `appUrl` to `http://localhost:8080`, `allowInitialPasswordSetWithoutStepUp: true`, MFA off | Combine with a base profile, e.g. `local,playwright-test` (see [TESTING.md](TESTING.md)) |
-| `docker-keycloak` | [`application-docker-keycloak.yml-example`](../src/main/resources/application-docker-keycloak.yml-example) → `application-docker-keycloak.yml` (gitignored) | OIDC login against the bundled Keycloak stack | Keycloak OAuth2 client/provider from `DS_SPRING_USER_KEYCLOAK_*` env vars, insecure session cookie, `audit.flushOnWrite: true` | `--spring.profiles.active=docker-keycloak`, normally set for you as `SPRING_PROFILES_ACTIVE` inside `docker-compose-keycloak.yml` |
+| `docker-keycloak` | [`application-docker-keycloak.yml`](../src/main/resources/application-docker-keycloak.yml) (tracked; holds only `${...}` placeholders, nothing to copy) | OIDC login against the bundled Keycloak stack; see [`keycloak/README.md`](../keycloak/README.md) and [AUTHENTICATION.md#keycloak](AUTHENTICATION.md#keycloak) for the full walkthrough | Adds the Keycloak OAuth2 client/provider from `DS_SPRING_USER_KEYCLOAK_*` env vars (deliberately no `issuer-uri`), insecure session cookie | `--spring.profiles.active=docker-keycloak`, normally set for you as `SPRING_PROFILES_ACTIVE` inside `docker-compose-keycloak.yml` |
| `mfa` | [`application-mfa.yml`](../src/main/resources/application-mfa.yml) | Add-on: require PASSWORD + WEBAUTHN | `user.mfa.enabled: true` (base `application.yml:126` has it `false`); once enabled, the framework auto-unprotects the configured MFA entry-point URIs at runtime, including the challenge page, so a partially-authenticated user can reach them; the profile's yml additionally adds the passkey enrollment endpoints `/webauthn/register/options` and `/webauthn/register` to `unprotectedURIs` (line 25) so that user can register their first passkey; `allowInitialPasswordSetWithoutStepUp: true` | Combine with a base profile, e.g. `local,mfa` |
| `registration-guard` | none (no yml; `@Profile("registration-guard")` on [`DomainRegistrationGuard`](../src/main/java/com/digitalsanctuary/spring/demo/registration/DomainRegistrationGuard.java)) | Add-on: domain-restricted registration demo | Activates a `RegistrationGuard` bean that restricts form/passwordless registration to one email domain (`registration.guard.allowed-domain`, default `@example.com`); OAuth2/OIDC registration is unaffected | Combine with a base profile, e.g. `local,registration-guard` |
@@ -62,7 +62,7 @@ Recognized elsewhere (fall back to a demo default when unset):
| `APP_URL` | Canonical base URL for security email links in `prd` (`application-prd.yml:47`, default `https://example.com`). |
| `DATABASE_URL`, `DATABASE_USERNAME`, `DATABASE_PASSWORD` | Production datasource (`application-prd.yml:10-12`). |
| `WEBAUTHN_RP_ID`, `WEBAUTHN_RP_NAME`, `WEBAUTHN_ALLOWED_ORIGINS` | WebAuthn relying-party identity in `prd` (`application-prd.yml:41-43`). |
-| `DS_SPRING_USER_KEYCLOAK_CLIENT_ID`, `_CLIENT_SECRET`, `_PROVIDER_ISSUER_URI`, `_PROVIDER_AUTHORIZATION_URI`, `_PROVIDER_TOKEN_URI`, `_PROVIDER_USER_INFO_URI`, `_PROVIDER_JWK_SET_URI` | Keycloak OAuth2 client and provider endpoints for `docker-keycloak` (`application-docker-keycloak.yml-example:29-45`); supplied by [`keycloak.env`](../keycloak.env) when you run `docker-compose-keycloak.yml`. |
+| `DS_SPRING_USER_KEYCLOAK_CLIENT_ID`, `_CLIENT_SECRET`, `_PROVIDER_AUTHORIZATION_URI`, `_PROVIDER_TOKEN_URI`, `_PROVIDER_USER_INFO_URI`, `_PROVIDER_JWK_SET_URI` | Keycloak OAuth2 client and provider endpoints for `docker-keycloak`, consumed in `application-docker-keycloak.yml:19-20,35,48-50`; supplied by [`keycloak.env`](../keycloak.env) when you run `docker-compose-keycloak.yml`. There is no `_PROVIDER_ISSUER_URI`: `issuer-uri` is deliberately left unset, see [`keycloak/README.md`](../keycloak/README.md). |
| `SELINUX_LABEL` | Suffix on the mailserver's bind-mounted config path in `compose.yaml`/`docker-compose-keycloak.yml`. Unset by default; Docker Compose prints a harmless warning about it. |
Any framework property can also be set through Spring's relaxed binding (`SCREAMING_SNAKE_CASE` of
diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md
index 49e548c..c6f44b2 100644
--- a/docs/DEVELOPMENT.md
+++ b/docs/DEVELOPMENT.md
@@ -49,8 +49,8 @@ to `https://`, not conditional on scheme. The port comes from the framework's
server always listens on plain HTTP at its default port, 35729, regardless of that flag, so:
- `spring.devtools.livereload.https=true`, already set by
- `application-local.yml-example:107` and `application-docker-keycloak.yml-example:60` (so copying
- either example file puts you on this path immediately): the script requests
+ `application-local.yml-example:107` (so copying that example file puts you on this path
+ immediately; the `docker-keycloak` profile does not set this property): the script requests
`https://localhost:35739/livereload.js`. Nothing listens there by default; run
`mitmproxy --mode reverse:http://localhost:35729 -p 35739` to terminate TLS on 35739 and forward to
the real server on 35729.
@@ -76,7 +76,10 @@ that automate the tunnel + proxy pair for the first case. See:
user) and runs all three. The app container's healthcheck polls `GET /actuator/health`
(`compose.yaml:86-90`), the only actuator endpoint left unauthenticated.
- [`docker-compose-keycloak.yml`](../docker-compose-keycloak.yml): the same app + MariaDB + mail
- setup plus a Keycloak container, for testing OIDC login. See [AUTHENTICATION.md](AUTHENTICATION.md#keycloak).
+ setup plus a Keycloak container, for testing OIDC login. Start with
+ `docker compose -f docker-compose-keycloak.yml up -d --build --wait`. See
+ [`keycloak/README.md`](../keycloak/README.md) for ports, credentials, and the login walkthrough, and
+ [AUTHENTICATION.md#keycloak](AUTHENTICATION.md#keycloak) for the OIDC mechanics.
## Gradle tasks
From 329da6f6bcb054e4d83dad76bfbc40d707afa495 Mon Sep 17 00:00:00 2001
From: Devon Hillard
Date: Sat, 15 Aug 2026 01:33:41 -0600
Subject: [PATCH 17/26] docs: add docs/AUTHENTICATION.md
---
docs/AUTHENTICATION.md | 263 +++++++++++++++++++++++++++++++++++++++++
1 file changed, 263 insertions(+)
create mode 100644 docs/AUTHENTICATION.md
diff --git a/docs/AUTHENTICATION.md b/docs/AUTHENTICATION.md
new file mode 100644
index 0000000..a949e79
--- /dev/null
+++ b/docs/AUTHENTICATION.md
@@ -0,0 +1,263 @@
+# Authentication
+
+Every authentication path this demo can exercise, how to run it, and where its code and configuration
+live. Per-profile overrides are in [CONFIGURATION.md](CONFIGURATION.md), the framework's full property
+list in [CONFIG.md](https://github.com/devondragon/SpringUserFramework/blob/main/CONFIG.md).
+All pages below are served by the framework's `UserPageController` unless noted, with paths from
+`user.security.*URI` in [`application.yml`](../src/main/resources/application.yml) (lines 165-177), and
+call `/user/*` endpoints in its `UserAPI`. The demo supplies the HTML and JavaScript,
+[`templates/user/`](../src/main/resources/templates/user) and
+[`static/js/user/`](../src/main/resources/static/js/user), one module per page (page-to-endpoint map:
+[EXTENDING.md](EXTENDING.md#reference-templates-javascript-and-messages)). Logs go to
+`/opt/app/logs/user-app.log` and `/opt/app/logs/user-audit.log` (`application.yml:99`, `:136`), and
+failed logins and lockouts land in the audit log.
+
+## Username and password with email verification
+
+1. Register at `/user/register.html` ([`register.html`](../src/main/resources/templates/user/register.html),
+ [`register.js`](../src/main/resources/static/js/user/register.js)), which posts JSON to
+ `POST /user/registration`, not form data.
+2. What happens next depends on `user.registration.sendVerificationEmail`:
+ - `true` (base `application.yml:113`): the account is created disabled, a verification email is sent,
+ and the browser lands on `/user/registration-pending-verification.html`. The emailed link is
+ `GET /user/registrationConfirm?token=...`, which enables the account. Lost it? Request another at
+ `/user/request-new-verification-email.html`
+ ([`resend-verification.js`](../src/main/resources/static/js/user/resend-verification.js) posts
+ `POST /user/resendRegistrationToken`).
+ - `false`: the account is created enabled, the framework logs the user straight in, and the browser
+ lands on `/user/registration-complete.html`. `application-local.yml-example:128` sets it false and
+ the Docker demo stack sets `USER_REGISTRATION_SENDVERIFICATIONEMAIL: "false"` (`compose.yaml:85`),
+ so neither documented run path needs a working SMTP server.
+3. Log in at `/user/login.html` ([`login.js`](../src/main/resources/static/js/user/login.js)). The form
+ posts to `/user/login`; success redirects to `/index.html?messageKey=message.login.success`. Ten
+ failed attempts lock the account for 30 minutes (`application.yml:146-147`).
+4. Forgot password: `/user/forgot-password.html` posts `POST /user/resetPassword`, which emails a token
+ link to `/user/forgot-password-change.html`; that page posts `POST /user/savePassword`
+ ([`forgot-password.js`](../src/main/resources/static/js/user/forgot-password.js),
+ [`reset-password.js`](../src/main/resources/static/js/user/reset-password.js)). Both reset steps need
+ real mail, unlike registration. To change a password you know, `/user/update-password.html` posts
+ `POST /user/updatePassword`.
+
+## Passkeys
+
+WebAuthn is on by default here. Four keys configure it, `application.yml:116-120`:
+`user.webauthn.enabled: true`; `rpId: localhost`, the relying party ID, which must equal the browser's
+hostname; `rpName: Spring User Framework Demo`, the name shown in the browser's passkey prompt; and
+`allowedOrigins: http://localhost:8080`, which must equal the browser origin exactly, port included.
+Passkey sign-in additionally needs `/webauthn/authenticate/**` and `/login/webauthn` in
+`user.security.unprotectedURIs` (`application.yml:160`), because those two endpoints are called before
+a session exists. Enrollment and credential management sit behind authentication and need no entry.
+
+In the UI: log in with a password, open `/user/update-user.html`, name the passkey and click "Add
+Passkey" ([`webauthn-register.js`](../src/main/resources/static/js/user/webauthn-register.js)); sign in
+with it from the "Sign in with Passkey" button on `/user/login.html`, shown only when the browser
+reports WebAuthn support; rename and delete from that same panel
+([`webauthn-manage.js`](../src/main/resources/static/js/user/webauthn-manage.js)); labels are capped at
+64 characters.
+
+HTTP works on `localhost`; any other host needs HTTPS, so run `ngrok http 8080` and set `rpId` to the
+tunnel hostname and `allowedOrigins` to the full `https://` origin (`application-prd.yml:41-43` drives
+all three from `WEBAUTHN_RP_ID`, `WEBAUTHN_RP_NAME` and `WEBAUTHN_ALLOWED_ORIGINS`). The `user_entities`
+and `user_credentials` tables are created by Hibernate (`ddl-auto: update`, `application.yml:58`), so
+there is no migration step. Framework reference:
+[WebAuthn / Passkeys](https://github.com/devondragon/SpringUserFramework/blob/main/README.md#webauthn--passkeys).
+
+## Passwordless registration and setting a first password
+
+`/user/register.html` shows a "Passwordless (Passkey)" toggle when the browser supports WebAuthn
+([`register.js:31-64`](../src/main/resources/static/js/user/register.js)). In that mode the password
+fields are hidden and the form posts `{firstName, lastName, email}` to
+`POST /user/registration/passwordless` instead; that path is in `unprotectedURIs`
+(`application.yml:160`), without which it would be unreachable under `defaultAction: deny`. The account
+is created with no password, and the user then enrolls a passkey from `/user/update-user.html` as above.
+A passkey-only account can add a password later. `/user/update-password.html` asks
+`GET /user/auth-methods` on load ([`auth-methods.js`](../src/main/resources/static/js/user/auth-methods.js));
+when `hasPassword` is false it drops the current-password field and posts `POST /user/setPassword`
+instead of `/user/updatePassword`
+([`update-password.js:20-42,68-99`](../src/main/resources/static/js/user/update-password.js)).
+That endpoint is guarded (framework SUF-02): with no current password to verify it requires a
+`StepUpService` bean, and returns `403` when none exists unless
+`user.security.allowInitialPasswordSetWithoutStepUp` is `true`. This demo has no `StepUpService`, so it
+sets the flag true where the flow has to be demonstrable (`application-local.yml-example:138`,
+`application-mfa.yml:24`) and leaves it at the secure default `false` in `prd` (`application-prd.yml:50-52`).
+
+## MFA
+
+The `mfa` profile turns on `user.mfa.enabled` (`application-mfa.yml:20`), which is `false` in the base
+config (`application.yml:126`). The factor list is `PASSWORD` then `WEBAUTHN`
+(`application.yml:127-129`), so a password login alone reaches no protected page: the framework
+redirects the partially-authenticated session to `user.mfa.webauthnEntryPointUri`,
+`/user/mfa/webauthn-challenge.html` (`application.yml:133`). Walk it through in this order:
+
+1. Run with `local` only and enroll a passkey (see [Passkeys](#passkeys)). A user with no passkey
+ cannot satisfy the WEBAUTHN factor and is locked out of every protected page, which is why the base
+ config keeps MFA off (`application.yml:123-125`).
+2. Restart with both profiles: `./gradlew bootRun --args='--spring.profiles.active=local,mfa'`.
+3. Log in with your password at `/user/login.html`. You land on `/user/mfa/webauthn-challenge.html`.
+4. Click "Verify with Passkey"
+ ([`mfa-webauthn-challenge.js`](../src/main/resources/static/js/user/mfa-webauthn-challenge.js)) and
+ the session becomes fully authenticated.
+
+Two unprotection details, both explained in the comments at `application-mfa.yml:10-16`. The framework
+unprotects the configured factor entry-point URIs at runtime, challenge page included, so the
+partial-auth redirect cannot loop back onto itself. Separately, the profile adds
+`/webauthn/register/options` and `/webauthn/register` to `unprotectedURIs` (`application-mfa.yml:25`)
+so a partially-authenticated user can still enroll a first passkey; Spring Security still requires an
+authenticated principal to store a credential, so this only relaxes the all-factors requirement.
+The demo owns the challenge page,
+[`templates/user/mfa/webauthn-challenge.html`](../src/main/resources/templates/user/mfa/webauthn-challenge.html),
+plus its mapping in
+[`PageController:59-62`](../src/main/java/com/digitalsanctuary/spring/demo/controller/PageController.java).
+Coverage: [`playwright/tests/mfa/`](../playwright/tests/mfa).
+
+## OAuth2 with Google and Facebook
+
+`user.registration.googleEnabled` and `user.registration.facebookEnabled` decide whether the buttons
+render on `/user/login.html` and `/user/register.html`; both are `false` in `application.yml:114-115`
+and `true` in `application-local.yml-example:129-130`. The buttons link to `/oauth2/authorization/google`
+and `/oauth2/authorization/facebook`, and `/oauth2/authorization/*` is already in `unprotectedURIs`
+(`application.yml:160`).
+Client IDs and secrets belong in `application-local.yml` (gitignored), never in `application.yml`; the
+example file has a filled-in shape to copy at
+[`application-local.yml-example:35-57`](../src/main/resources/application-local.yml-example), and the
+framework's [SSO OAuth2 with Google and
+Facebook](https://github.com/devondragon/SpringUserFramework/blob/main/README.md#sso-oauth2-with-google-and-facebook)
+section has the YAML block. Neither provider will redirect back to `http://localhost:8080`: run
+`ngrok http 8080`, register `https:///login/oauth2/code/google` (and `/facebook`) as the
+callback in each provider console, and set the same value as `redirect-uri` in your local file. Set
+`user.security.appUrl` to the tunnel URL too (`application-local.yml-example:132-135`), otherwise
+emailed verification and reset links still point at localhost.
+
+## Keycloak
+
+`docker compose -f docker-compose-keycloak.yml up -d --build` runs the app against a bundled Keycloak
+25.0.6 alongside the normal form login. The realm, its client and the `demo` user are imported on first
+start from [`keycloak/realm/realm-export.json`](../keycloak/realm/realm-export.json); stack contents and
+how to re-export are in [`keycloak/README.md`](../keycloak/README.md). The essentials:
+
+| What | Address | Login |
+| --- | --- | --- |
+| Demo app | http://localhost:8080 | `demo` / `demo`, through Keycloak |
+| Keycloak admin console | http://localhost:8180 | `admin` / `admin`, master realm, console only |
+| Keycloak HTTPS | https://localhost:8143 | self-signed certificate |
+| Keycloak management | port 9001 (container 9000) | `/health/*` and `/metrics`, HTTPS |
+| MariaDB | localhost:3307 | `springuser` / `springuser` |
+
+Open http://localhost:8080/user/login.html, click "Login with Keycloak", sign in as `demo` / `demo`.
+First login creates a local account with provider `KEYCLOAK` and email `demo@example.com`. The realm is
+`demo`, not `master`, so every OIDC URL is `/realms/demo/...`; `admin` / `admin` is a master realm
+account and cannot sign in to the demo app.
+The compose file sets `SPRING_PROFILES_ACTIVE: docker-keycloak`, and every value in
+[`application-docker-keycloak.yml`](../src/main/resources/application-docker-keycloak.yml) comes from an
+environment variable in [`keycloak.env`](../keycloak.env): `DS_SPRING_USER_KEYCLOAK_CLIENT_ID`,
+`..._CLIENT_SECRET`, and `..._PROVIDER_AUTHORIZATION_URI` / `_TOKEN_URI` / `_USER_INFO_URI` /
+`_JWK_SET_URI`. The authorization URI points at `http://localhost:8180` because only the browser calls
+it; the other three point at `http://keycloak:8080`, reached over the compose network. The button is
+shown by `user.registration.keycloakEnabled: true` (`application-docker-keycloak.yml:64`).
+One limitation: `issuer-uri` is deliberately unset. Keycloak stamps the ID token `iss`
+with its frontend URL (`http://localhost:8180/realms/demo`), which the app container cannot reach, and
+Spring Boot treats `issuer-uri` as a discovery location it fetches at startup. No single URL works from
+both a host browser and a container without editing `/etc/hosts`, so the property is omitted and Spring
+Security skips only the `iss` comparison; signature (via `jwk-set-uri`), audience, nonce and expiry are
+still validated. With real DNS in front of Keycloak, set `issuer-uri` and drop the two-hostname split.
+Same reasoning in the file's comment, `application-docker-keycloak.yml:36-46`.
+
+## Registration guard
+
+The framework's `RegistrationGuard` SPI lets an application allow or deny each registration attempt
+(invite codes, allowlists, domain restrictions). Every guard bean is consulted for form, passwordless
+and OAuth2/OIDC sign-ups, and one denial rejects the registration with that guard's message. Full SPI
+contract, including how to write your own:
+[docs/REGISTRATION-GUARD.md](https://github.com/devondragon/SpringUserFramework/blob/main/docs/REGISTRATION-GUARD.md).
+This demo ships
+[`DomainRegistrationGuard`](../src/main/java/com/digitalsanctuary/spring/demo/registration/DomainRegistrationGuard.java),
+which restricts form and passwordless registration to one email domain while allowing all OAuth2/OIDC
+registration. It is annotated `@Profile("registration-guard")`, so the default demo is unaffected.
+
+```bash
+# Only @example.com addresses can register via the form; OAuth2/OIDC still allowed
+./gradlew bootRun --args='--spring.profiles.active=local,registration-guard'
+
+# Override the allowed domain. It goes inside --args, as a Spring Boot argument, so that it reaches the
+# forked application; a -D after the task name sets it on the Gradle JVM only and is not forwarded.
+./gradlew bootRun \
+ --args='--spring.profiles.active=local,registration-guard --registration.guard.allowed-domain=@mycompany.com'
+```
+
+| Setting | Default | Purpose |
+| --- | --- | --- |
+| `registration-guard` profile | off | Activates the sample guard bean |
+| `registration.guard.allowed-domain` | `@example.com` | Domain form/passwordless registrations must match |
+
+A non-matching address is denied with `Registration is restricted to email addresses.`
+
+## Remember-me
+
+`user.security.rememberMe.enabled: true` (`application.yml:151-152`) makes Spring Security issue a
+`remember-me` cookie when the login form posts the checkbox on
+[`login.html:78`](../src/main/resources/templates/user/login.html).
+The signing key is `${REMEMBER_ME_KEY:${random.uuid}}` (`application.yml:157`). The random fallback means
+the demo never runs on a publicly known key, at the cost of invalidating every remember-me cookie on
+restart; set `REMEMBER_ME_KEY` to a long random value to keep tokens valid across restarts and instances.
+The `prd` profile has no fallback (`application-prd.yml:57`), so startup fails unless the variable is
+set, and it forces `useSecureCookie: true` (`application-prd.yml:61`) because Spring's default derives
+that from `request.isSecure()`, false behind a TLS-terminating proxy. Two commented options sit next to
+the key (`application.yml:158-159`): `tokenValiditySeconds` (default 14 days) and `usePersistentTokens`,
+which stores tokens in the `persistent_logins` table so they can be revoked server-side.
+[`playwright/tests/auth/remember-me.spec.ts`](../playwright/tests/auth/remember-me.spec.ts) covers it end
+to end: cookie issued only when the box is checked, then auto-login from it once the session cookie is
+gone.
+
+## Admin
+
+`/admin/actions.html` looks up a user by email and locks or unlocks the account. It is
+[`AdminController`](../src/main/java/com/digitalsanctuary/spring/demo/controller/AdminController.java),
+guarded by `@PreAuthorize("hasAuthority('ADMIN_PRIVILEGE')")`, and the "Admin Actions" menu item appears
+under the same authority in
+[`fragments/header.html:48-49`](../src/main/resources/templates/fragments/header.html). The form posts
+JSON `{"email": "..."}` to `POST /admin/lockAccount` or `POST /admin/unlockAccount`
+([`AdminAPIController`](../src/main/java/com/digitalsanctuary/spring/demo/controller/AdminAPIController.java),
+same authority, CSRF required), and renders the returned `JSONResponse` message: 200 on success, 400
+for a blank email, 404 for an unknown one.
+An admin lock sets the same flag the failed-login lockout uses, so it expires on the same timer,
+`user.security.accountLockoutDuration: 30` minutes (`application.yml:147`); set that to `-1` to make a
+lock last until an admin unlocks it. Locking does not invalidate a session the user already has, it
+blocks the next login.
+
+**Getting an admin user.** The demo seeds no users, only roles: `ROLE_ADMIN`, `ROLE_MANAGER`,
+`ROLE_USER` and their privileges are created at startup from `user.roles.roles-and-privileges`
+(`application.yml:200-222`), and new registrations get `ROLE_USER`. Register normally, then grant the
+role in the database: the framework's tables are `user_account`, `role`, and the join table
+`users_roles(user_id, role_id)`.
+
+```sql
+INSERT INTO users_roles (user_id, role_id)
+SELECT u.id, r.id FROM user_account u JOIN `role` r ON r.name = 'ROLE_ADMIN'
+WHERE u.email = 'you@example.com';
+```
+
+Get a shell on the right database first: `docker exec -it springuserframeworkdemoapp-mariadb-1 mariadb
+-uspringuser -pspringuser springuser` for `./gradlew bootRun`, or the same with `springuser-db` for the
+`docker compose up` stack. Log out and back in afterwards, since authorities are loaded at login.
+
+## Troubleshooting
+
+**Passkey registration or login fails.** Check, in order: `user.webauthn.enabled` is `true`
+(`application.yml:117`); `rpId` equals the browser's hostname (`localhost` locally); `allowedOrigins`
+equals the browser origin exactly, scheme and port included (`http://localhost:8080`, not `https://`,
+not `127.0.0.1`); `/webauthn/authenticate/**` and `/login/webauthn` are in `unprotectedURIs`
+(`application.yml:160`). Anything other than `localhost` requires HTTPS, so tunnel with
+`ngrok http 8080` and update `rpId` and `allowedOrigins` to match. WebAuthn API errors surface in the
+browser console, not the server log.
+
+**OAuth2 or OIDC login fails or redirects wrongly.** Check the client ID and secret in
+`application-local.yml` (for Keycloak, `DS_SPRING_USER_KEYCLOAK_*` in `keycloak.env`) against what the
+provider has, and that the provider's registered callback matches your `redirect-uri`, default shape
+`{baseUrl}/login/oauth2/code/{registrationId}`. Google and Facebook will not call back to `localhost`;
+tunnel with `ngrok http 8080`. For Keycloak, remember the realm is `demo`, so URLs are
+`/realms/demo/...` and the app login is `demo` / `demo`, while `admin` / `admin` only opens the admin
+console at http://localhost:8180. A realm re-exported from that console masks the client secret as
+`**********`, which then no longer matches `keycloak.env` and breaks login: use the `kc.sh export`
+command in [`keycloak/README.md`](../keycloak/README.md). `com.digitalsanctuary: DEBUG` is already on
+in the Keycloak profile (`application-docker-keycloak.yml:7-10`); add
+`org.springframework.security: DEBUG` to trace the full OIDC exchange.
From 77ff8546456d66f6b1f8735d6c6c024db0154d4f Mon Sep 17 00:00:00 2001
From: Devon Hillard
Date: Sat, 15 Aug 2026 01:46:28 -0600
Subject: [PATCH 18/26] docs: refresh application.yml line citations in
extending guide
---
docs/EXTENDING.md | 20 ++++++++++----------
1 file changed, 10 insertions(+), 10 deletions(-)
diff --git a/docs/EXTENDING.md b/docs/EXTENDING.md
index c332fad..7e3f1b0 100644
--- a/docs/EXTENDING.md
+++ b/docs/EXTENDING.md
@@ -53,7 +53,7 @@ it; `EventRegistration` rows go with it through `cascade = ALL, orphanRemoval =
In your app: register one such listener per aggregate that holds a foreign key to the user, and do the work in the
event's transaction so a failed cleanup rolls the deletion back. Whether the account is deleted or only disabled is
-controlled by `user.actuallyDeleteAccount` ([application.yml:103](../src/main/resources/application.yml)).
+controlled by `user.actuallyDeleteAccount` ([application.yml:111](../src/main/resources/application.yml)).
## Building your own domain on the framework: events
@@ -76,9 +76,9 @@ framework only for identity and authorization:
rather than an API.
The authorities in those annotations are not hard-coded in Java; they come from the framework's role configuration in
-[application.yml:192-214](../src/main/resources/application.yml). `user.roles.roles-and-privileges` grants
-`CREATE_EVENT_PRIVILEGE`, `DELETE_EVENT_PRIVILEGE`, and `UPDATE_EVENT_PRIVILEGE` to `ROLE_ADMIN` (lines 200-202) and
-`REGISTER_FOR_EVENT_PRIVILEGE` to `ROLE_USER` (line 211). `user.roles.role-hierarchy` (lines 212-214) declares
+[application.yml:200-222](../src/main/resources/application.yml). `user.roles.roles-and-privileges` grants
+`CREATE_EVENT_PRIVILEGE`, `DELETE_EVENT_PRIVILEGE`, and `UPDATE_EVENT_PRIVILEGE` to `ROLE_ADMIN` (lines 208-210) and
+`REGISTER_FOR_EVENT_PRIVILEGE` to `ROLE_USER` (line 219). `user.roles.role-hierarchy` (lines 220-222) declares
`ROLE_ADMIN > ROLE_MANAGER > ROLE_USER`, so an admin also holds the user privileges without being granted them twice.
The framework creates the roles and privileges from this configuration at startup.
@@ -147,7 +147,7 @@ reference set. What to copy:
`/user/mfa/webauthn-challenge.html` itself in
[PageController](../src/main/java/com/digitalsanctuary/spring/demo/controller/PageController.java), because that path
is the `user.mfa.webauthnEntryPointUri` value at
- [application.yml:125](../src/main/resources/application.yml). Copying `templates/user/mfa/` means copying that
+ [application.yml:133](../src/main/resources/application.yml). Copying `templates/user/mfa/` means copying that
mapping too.
- [templates/layout.html](../src/main/resources/templates/layout.html) and
[templates/fragments/](../src/main/resources/templates/fragments) (`header.html`, `footer.html`): the layout dialect
@@ -176,7 +176,7 @@ reference set. What to copy:
[static/js/utils/password-validation.js](../src/main/resources/static/js/utils/password-validation.js) (strength
meter) are imported by the page modules, so copy them too.
- [messages/messages.properties](../src/main/resources/messages/messages.properties), wired by
- `spring.messages.basename: messages/messages` ([application.yml:71-72](../src/main/resources/application.yml)). The
+ `spring.messages.basename: messages/messages` ([application.yml:79-80](../src/main/resources/application.yml)). The
framework appends its own bundle after yours, so redefining a framework key here (the file overrides `auth.message.*`,
`email.*`, and the password-policy messages) replaces the library text.
@@ -198,15 +198,15 @@ production configuration.
These need no code in the demo at all:
-- MFA: `user.mfa` ([application.yml:114-125](../src/main/resources/application.yml)) declares the factors `PASSWORD`
- and `WEBAUTHN` (lines 119-121) and the entry-point URIs, but is disabled at line 118. The `mfa` profile
+- MFA: `user.mfa` ([application.yml:122-133](../src/main/resources/application.yml)) declares the factors `PASSWORD`
+ and `WEBAUTHN` (lines 127-129) and the entry-point URIs, but is disabled at line 126. The `mfa` profile
([application-mfa.yml](../src/main/resources/application-mfa.yml)) only flips `enabled: true`, allows the initial
password-set flow without a `StepUpService`, and adds the passkey registration endpoints to the unprotected list so a
new user can enroll.
- URL protection: `user.security.defaultAction: deny` plus `user.security.unprotectedURIs`
- ([application.yml:142,152](../src/main/resources/application.yml)) decide what is public; the demo adds its own
+ ([application.yml:150,160](../src/main/resources/application.yml)) decide what is public; the demo adds its own
`/event/**` and static paths there.
-- Remember-me: `user.security.rememberMe` ([application.yml:143-151](../src/main/resources/application.yml)) enables
+- Remember-me: `user.security.rememberMe` ([application.yml:151-159](../src/main/resources/application.yml)) enables
the cookie, with the signing key read from `REMEMBER_ME_KEY` and a random per-start fallback.
Every property above is documented in [CONFIGURATION.md](CONFIGURATION.md) and in the framework's
From b001ebd704da62b01c013db3ac889daf4f8253b3 Mon Sep 17 00:00:00 2001
From: Devon Hillard
Date: Sat, 15 Aug 2026 01:49:28 -0600
Subject: [PATCH 19/26] docs: correct the mfa walkthrough, keycloak start and
oauth callback notes
---
docs/AUTHENTICATION.md | 149 ++++++++++++++++++++++-------------------
1 file changed, 80 insertions(+), 69 deletions(-)
diff --git a/docs/AUTHENTICATION.md b/docs/AUTHENTICATION.md
index a949e79..2c1470f 100644
--- a/docs/AUTHENTICATION.md
+++ b/docs/AUTHENTICATION.md
@@ -31,8 +31,9 @@ failed logins and lockouts land in the audit log.
3. Log in at `/user/login.html` ([`login.js`](../src/main/resources/static/js/user/login.js)). The form
posts to `/user/login`; success redirects to `/index.html?messageKey=message.login.success`. Ten
failed attempts lock the account for 30 minutes (`application.yml:146-147`).
-4. Forgot password: `/user/forgot-password.html` posts `POST /user/resetPassword`, which emails a token
- link to `/user/forgot-password-change.html`; that page posts `POST /user/savePassword`
+4. Forgot password: `/user/forgot-password.html` posts `POST /user/resetPassword`, which emails a link
+ to `GET /user/changePassword?token=...`. That endpoint validates the token and redirects to
+ `/user/forgot-password-change.html`, which posts `POST /user/savePassword`
([`forgot-password.js`](../src/main/resources/static/js/user/forgot-password.js),
[`reset-password.js`](../src/main/resources/static/js/user/reset-password.js)). Both reset steps need
real mail, unlike registration. To change a password you know, `/user/update-password.html` posts
@@ -40,26 +41,24 @@ failed logins and lockouts land in the audit log.
## Passkeys
-WebAuthn is on by default here. Four keys configure it, `application.yml:116-120`:
+WebAuthn is on by default here. Four keys configure it, `application.yml:117-120`:
`user.webauthn.enabled: true`; `rpId: localhost`, the relying party ID, which must equal the browser's
-hostname; `rpName: Spring User Framework Demo`, the name shown in the browser's passkey prompt; and
+hostname; `rpName: Spring User Framework Demo`, shown in the browser's passkey prompt; and
`allowedOrigins: http://localhost:8080`, which must equal the browser origin exactly, port included.
-Passkey sign-in additionally needs `/webauthn/authenticate/**` and `/login/webauthn` in
-`user.security.unprotectedURIs` (`application.yml:160`), because those two endpoints are called before
-a session exists. Enrollment and credential management sit behind authentication and need no entry.
+Sign-in additionally needs `/webauthn/authenticate/**` and `/login/webauthn` in
+`user.security.unprotectedURIs` (`application.yml:160`), since both are called before a session exists;
+enrollment and credential management sit behind authentication and need no entry.
In the UI: log in with a password, open `/user/update-user.html`, name the passkey and click "Add
Passkey" ([`webauthn-register.js`](../src/main/resources/static/js/user/webauthn-register.js)); sign in
-with it from the "Sign in with Passkey" button on `/user/login.html`, shown only when the browser
-reports WebAuthn support; rename and delete from that same panel
-([`webauthn-manage.js`](../src/main/resources/static/js/user/webauthn-manage.js)); labels are capped at
-64 characters.
-
+with it from the "Sign in with Passkey" button on `/user/login.html`, shown only when the browser reports
+WebAuthn support; rename and delete from that same panel, labels capped at 64 characters
+([`webauthn-manage.js`](../src/main/resources/static/js/user/webauthn-manage.js)).
HTTP works on `localhost`; any other host needs HTTPS, so run `ngrok http 8080` and set `rpId` to the
-tunnel hostname and `allowedOrigins` to the full `https://` origin (`application-prd.yml:41-43` drives
-all three from `WEBAUTHN_RP_ID`, `WEBAUTHN_RP_NAME` and `WEBAUTHN_ALLOWED_ORIGINS`). The `user_entities`
-and `user_credentials` tables are created by Hibernate (`ddl-auto: update`, `application.yml:58`), so
-there is no migration step. Framework reference:
+tunnel hostname and `allowedOrigins` to the full `https://` origin (`application-prd.yml:41-43` drives all
+three from `WEBAUTHN_RP_ID`, `WEBAUTHN_RP_NAME` and `WEBAUTHN_ALLOWED_ORIGINS`). The `user_entities` and
+`user_credentials` tables are created by Hibernate (`ddl-auto: update`, `application.yml:58`), so there is
+no migration step. Framework reference:
[WebAuthn / Passkeys](https://github.com/devondragon/SpringUserFramework/blob/main/README.md#webauthn--passkeys).
## Passwordless registration and setting a first password
@@ -74,26 +73,30 @@ A passkey-only account can add a password later. `/user/update-password.html` as
`GET /user/auth-methods` on load ([`auth-methods.js`](../src/main/resources/static/js/user/auth-methods.js));
when `hasPassword` is false it drops the current-password field and posts `POST /user/setPassword`
instead of `/user/updatePassword`
-([`update-password.js:20-42,68-99`](../src/main/resources/static/js/user/update-password.js)).
+([`update-password.js:19-42,68-99`](../src/main/resources/static/js/user/update-password.js)).
That endpoint is guarded (framework SUF-02): with no current password to verify it requires a
`StepUpService` bean, and returns `403` when none exists unless
`user.security.allowInitialPasswordSetWithoutStepUp` is `true`. This demo has no `StepUpService`, so it
sets the flag true where the flow has to be demonstrable (`application-local.yml-example:138`,
-`application-mfa.yml:24`) and leaves it at the secure default `false` in `prd` (`application-prd.yml:50-52`).
+`application-mfa.yml:24`, `application-playwright-test.yml:35`) and leaves it at the secure default
+`false` in `prd` (`application-prd.yml:50-52`).
## MFA
-The `mfa` profile turns on `user.mfa.enabled` (`application-mfa.yml:20`), which is `false` in the base
-config (`application.yml:126`). The factor list is `PASSWORD` then `WEBAUTHN`
-(`application.yml:127-129`), so a password login alone reaches no protected page: the framework
-redirects the partially-authenticated session to `user.mfa.webauthnEntryPointUri`,
-`/user/mfa/webauthn-challenge.html` (`application.yml:133`). Walk it through in this order:
+The `mfa` profile turns on `user.mfa.enabled` (`application-mfa.yml:20`), `false` in the base config
+(`application.yml:126`). The factor list is `PASSWORD` then `WEBAUTHN` (`application.yml:127-129`), so a
+password login alone reaches no protected page: a request for one is bounced to
+`user.mfa.webauthnEntryPointUri`, `/user/mfa/webauthn-challenge.html` (`application.yml:133`). Walk it
+through in this order:
1. Run with `local` only and enroll a passkey (see [Passkeys](#passkeys)). A user with no passkey
cannot satisfy the WEBAUTHN factor and is locked out of every protected page, which is why the base
config keeps MFA off (`application.yml:123-125`).
2. Restart with both profiles: `./gradlew bootRun --args='--spring.profiles.active=local,mfa'`.
-3. Log in with your password at `/user/login.html`. You land on `/user/mfa/webauthn-challenge.html`.
+3. Log in with your password at `/user/login.html`. Login itself still lands on the configured success
+ page, `/index.html?messageKey=message.login.success` (`application.yml:167`), which is unprotected;
+ the challenge redirect fires from the access-denied handler. So open any protected page, for example
+ `/user/update-user.html`, and you are redirected to `/user/mfa/webauthn-challenge.html`.
4. Click "Verify with Passkey"
([`mfa-webauthn-challenge.js`](../src/main/resources/static/js/user/mfa-webauthn-challenge.js)) and
the session becomes fully authenticated.
@@ -101,39 +104,49 @@ redirects the partially-authenticated session to `user.mfa.webauthnEntryPointUri
Two unprotection details, both explained in the comments at `application-mfa.yml:10-16`. The framework
unprotects the configured factor entry-point URIs at runtime, challenge page included, so the
partial-auth redirect cannot loop back onto itself. Separately, the profile adds
-`/webauthn/register/options` and `/webauthn/register` to `unprotectedURIs` (`application-mfa.yml:25`)
-so a partially-authenticated user can still enroll a first passkey; Spring Security still requires an
-authenticated principal to store a credential, so this only relaxes the all-factors requirement.
-The demo owns the challenge page,
+`/webauthn/register/options` and `/webauthn/register` to `unprotectedURIs` (`application-mfa.yml:25`) so
+a partially-authenticated user can still enroll a first passkey (Spring Security still requires an
+authenticated principal to store one, so this only relaxes the all-factors requirement). That relaxation
+is endpoint-level only: the enrollment panel lives on the protected `/user/update-user.html`, so there is
+no UI path to a first passkey once MFA is on. Hence step 1 above, and hence the E2E test calling
+`registerPasskey` from a page script
+([`mfa-flow.spec.ts:52-55`](../playwright/tests/mfa/mfa-flow.spec.ts)). The demo owns the challenge page,
[`templates/user/mfa/webauthn-challenge.html`](../src/main/resources/templates/user/mfa/webauthn-challenge.html),
plus its mapping in
[`PageController:59-62`](../src/main/java/com/digitalsanctuary/spring/demo/controller/PageController.java).
-Coverage: [`playwright/tests/mfa/`](../playwright/tests/mfa).
## OAuth2 with Google and Facebook
-`user.registration.googleEnabled` and `user.registration.facebookEnabled` decide whether the buttons
-render on `/user/login.html` and `/user/register.html`; both are `false` in `application.yml:114-115`
-and `true` in `application-local.yml-example:129-130`. The buttons link to `/oauth2/authorization/google`
-and `/oauth2/authorization/facebook`, and `/oauth2/authorization/*` is already in `unprotectedURIs`
-(`application.yml:160`).
-Client IDs and secrets belong in `application-local.yml` (gitignored), never in `application.yml`; the
-example file has a filled-in shape to copy at
-[`application-local.yml-example:35-57`](../src/main/resources/application-local.yml-example), and the
+`user.registration.googleEnabled` and `user.registration.facebookEnabled` decide whether the buttons render
+on `/user/login.html` and `/user/register.html`; both are `false` in `application.yml:114-115` and `true` in
+`application-local.yml-example:129-130`. They link to `/oauth2/authorization/google` and
+`/oauth2/authorization/facebook`, already covered by `/oauth2/authorization/*` in `unprotectedURIs`
+(`application.yml:160`). Client IDs and secrets belong in `application-local.yml` (gitignored), never in
+`application.yml`; copy the filled-in shape at
+[`application-local.yml-example:35-57`](../src/main/resources/application-local.yml-example), and see the
framework's [SSO OAuth2 with Google and
Facebook](https://github.com/devondragon/SpringUserFramework/blob/main/README.md#sso-oauth2-with-google-and-facebook)
-section has the YAML block. Neither provider will redirect back to `http://localhost:8080`: run
-`ngrok http 8080`, register `https:///login/oauth2/code/google` (and `/facebook`) as the
-callback in each provider console, and set the same value as `redirect-uri` in your local file. Set
-`user.security.appUrl` to the tunnel URL too (`application-local.yml-example:132-135`), otherwise
-emailed verification and reset links still point at localhost.
+for the YAML block. A localhost callback is not ruled out: Google exempts `http://localhost` redirect URIs
+from its HTTPS requirement, so `http://localhost:8080/login/oauth2/code/google` works once registered in
+the provider console and set as `redirect-uri`. Facebook enforces HTTPS on redirect URIs by default for
+apps created since March 2018, so a plain-HTTP localhost callback there depends on the app's settings,
+normally while it is in development mode. Reach for `ngrok http 8080` when you need a public HTTPS callback
+or want to test from another device; then also set `user.security.appUrl` to the tunnel URL
+(`application-local.yml-example:132-135`), or emailed links still point at localhost.
## Keycloak
-`docker compose -f docker-compose-keycloak.yml up -d --build` runs the app against a bundled Keycloak
-25.0.6 alongside the normal form login. The realm, its client and the `demo` user are imported on first
-start from [`keycloak/realm/realm-export.json`](../keycloak/realm/realm-export.json); stack contents and
-how to re-export are in [`keycloak/README.md`](../keycloak/README.md). The essentials:
+Start the stack, which runs the app against a bundled Keycloak 25.0.6 alongside the normal form login:
+
+```bash
+docker compose -f docker-compose-keycloak.yml up -d --build --wait
+```
+
+`--wait` holds until every container is healthy; plain `up -d` returns mid-boot. The first build resolves
+dependencies and runs `bootJar` inside the image, several minutes; later starts are under a minute. The
+realm, its client and the `demo` user are imported on first start from
+[`keycloak/realm/realm-export.json`](../keycloak/realm/realm-export.json); stack contents and re-export
+instructions are in [`keycloak/README.md`](../keycloak/README.md). The essentials:
| What | Address | Login |
| --- | --- | --- |
@@ -151,9 +164,9 @@ The compose file sets `SPRING_PROFILES_ACTIVE: docker-keycloak`, and every value
[`application-docker-keycloak.yml`](../src/main/resources/application-docker-keycloak.yml) comes from an
environment variable in [`keycloak.env`](../keycloak.env): `DS_SPRING_USER_KEYCLOAK_CLIENT_ID`,
`..._CLIENT_SECRET`, and `..._PROVIDER_AUTHORIZATION_URI` / `_TOKEN_URI` / `_USER_INFO_URI` /
-`_JWK_SET_URI`. The authorization URI points at `http://localhost:8180` because only the browser calls
-it; the other three point at `http://keycloak:8080`, reached over the compose network. The button is
-shown by `user.registration.keycloakEnabled: true` (`application-docker-keycloak.yml:64`).
+`_JWK_SET_URI`. The authorization URI points at `http://localhost:8180` because only the browser calls it;
+the other three point at `http://keycloak:8080`, over the compose network. The button is shown by
+`user.registration.keycloakEnabled: true` (`application-docker-keycloak.yml:64`).
One limitation: `issuer-uri` is deliberately unset. Keycloak stamps the ID token `iss`
with its frontend URL (`http://localhost:8180/realms/demo`), which the app container cannot reach, and
Spring Boot treats `issuer-uri` as a discovery location it fetches at startup. No single URL works from
@@ -164,15 +177,15 @@ Same reasoning in the file's comment, `application-docker-keycloak.yml:36-46`.
## Registration guard
-The framework's `RegistrationGuard` SPI lets an application allow or deny each registration attempt
-(invite codes, allowlists, domain restrictions). Every guard bean is consulted for form, passwordless
-and OAuth2/OIDC sign-ups, and one denial rejects the registration with that guard's message. Full SPI
-contract, including how to write your own:
+The framework's `RegistrationGuard` SPI lets an application allow or deny each registration attempt (invite
+codes, allowlists, domain restrictions). Every guard bean is consulted for form, passwordless and
+OAuth2/OIDC sign-ups, and one denial rejects the registration with that guard's message. Full SPI contract,
+including how to write your own:
[docs/REGISTRATION-GUARD.md](https://github.com/devondragon/SpringUserFramework/blob/main/docs/REGISTRATION-GUARD.md).
This demo ships
[`DomainRegistrationGuard`](../src/main/java/com/digitalsanctuary/spring/demo/registration/DomainRegistrationGuard.java),
which restricts form and passwordless registration to one email domain while allowing all OAuth2/OIDC
-registration. It is annotated `@Profile("registration-guard")`, so the default demo is unaffected.
+registration, annotated `@Profile("registration-guard")` so the default demo is unaffected.
```bash
# Only @example.com addresses can register via the form; OAuth2/OIDC still allowed
@@ -199,14 +212,13 @@ A non-matching address is denied with `Registration is restricted to em
The signing key is `${REMEMBER_ME_KEY:${random.uuid}}` (`application.yml:157`). The random fallback means
the demo never runs on a publicly known key, at the cost of invalidating every remember-me cookie on
restart; set `REMEMBER_ME_KEY` to a long random value to keep tokens valid across restarts and instances.
-The `prd` profile has no fallback (`application-prd.yml:57`), so startup fails unless the variable is
-set, and it forces `useSecureCookie: true` (`application-prd.yml:61`) because Spring's default derives
-that from `request.isSecure()`, false behind a TLS-terminating proxy. Two commented options sit next to
-the key (`application.yml:158-159`): `tokenValiditySeconds` (default 14 days) and `usePersistentTokens`,
-which stores tokens in the `persistent_logins` table so they can be revoked server-side.
+The `prd` profile has no fallback (`application-prd.yml:57`), so startup fails unless the variable is set,
+and it forces `useSecureCookie: true` (`application-prd.yml:61`) because Spring's default derives that
+from `request.isSecure()`, false behind a TLS-terminating proxy. Two commented options sit next to the key
+(`application.yml:158-159`): `tokenValiditySeconds` (default 14 days) and `usePersistentTokens`, which
+stores tokens in the `persistent_logins` table so they can be revoked server-side.
[`playwright/tests/auth/remember-me.spec.ts`](../playwright/tests/auth/remember-me.spec.ts) covers it end
-to end: cookie issued only when the box is checked, then auto-login from it once the session cookie is
-gone.
+to end: cookie issued only when the box is checked, then auto-login from it once the session cookie is gone.
## Admin
@@ -214,15 +226,14 @@ gone.
[`AdminController`](../src/main/java/com/digitalsanctuary/spring/demo/controller/AdminController.java),
guarded by `@PreAuthorize("hasAuthority('ADMIN_PRIVILEGE')")`, and the "Admin Actions" menu item appears
under the same authority in
-[`fragments/header.html:48-49`](../src/main/resources/templates/fragments/header.html). The form posts
-JSON `{"email": "..."}` to `POST /admin/lockAccount` or `POST /admin/unlockAccount`
+[`fragments/header.html:48-49`](../src/main/resources/templates/fragments/header.html). The form posts JSON
+`{"email": "..."}` to `POST /admin/lockAccount` or `POST /admin/unlockAccount`
([`AdminAPIController`](../src/main/java/com/digitalsanctuary/spring/demo/controller/AdminAPIController.java),
-same authority, CSRF required), and renders the returned `JSONResponse` message: 200 on success, 400
-for a blank email, 404 for an unknown one.
-An admin lock sets the same flag the failed-login lockout uses, so it expires on the same timer,
-`user.security.accountLockoutDuration: 30` minutes (`application.yml:147`); set that to `-1` to make a
-lock last until an admin unlocks it. Locking does not invalidate a session the user already has, it
-blocks the next login.
+same authority, CSRF required), and renders the returned `JSONResponse` message: 200 on success, 400 for a
+blank email, 404 for an unknown one. An admin lock sets the same flag the failed-login lockout uses, so it
+expires on the same timer, `user.security.accountLockoutDuration: 30` minutes (`application.yml:147`); set
+that to `-1` to make a lock last until an admin unlocks it. Locking does not invalidate a session the user
+already has, it blocks the next login.
**Getting an admin user.** The demo seeds no users, only roles: `ROLE_ADMIN`, `ROLE_MANAGER`,
`ROLE_USER` and their privileges are created at startup from `user.roles.roles-and-privileges`
From f18abebecf65fa5f4863eb34e7becd30513b65f0 Mon Sep 17 00:00:00 2001
From: Devon Hillard
Date: Sat, 15 Aug 2026 01:58:16 -0600
Subject: [PATCH 20/26] docs: rewrite README as a demo overview that links to
docs/
---
README.md | 961 +++++++++---------------------------------------------
1 file changed, 162 insertions(+), 799 deletions(-)
diff --git a/README.md b/README.md
index 308753e..543975d 100644
--- a/README.md
+++ b/README.md
@@ -1,828 +1,191 @@
# Spring User Framework Demo Application
[](https://opensource.org/licenses/Apache-2.0)
-[](https://www.oracle.com/java/technologies/downloads/)
-[](https://spring.io/projects/spring-boot)
-[](https://gradle.org/)
-[](https://www.docker.com/)
-[](contributing)
-[](README.md)
-
-A comprehensive demonstration application for the [Spring User Framework](https://github.com/devondragon/SpringUserFramework), showcasing how to implement user management features in a Spring Boot web application.
-
-
-
-## Table of Contents
-- [Overview](#overview)
-- [Version Compatibility](#version-compatibility)
-- [Features](#features)
-- [Prerequisites](#prerequisites)
-- [Quick Start](#quick-start)
-- [Testing](#testing)
-- [Configuration](#configuration)
-- [Project Structure](#project-structure)
-- [Running the Application](#running-the-application)
-- [Development Tools](#development-tools)
-- [API Documentation](#api-documentation)
-- [Architecture](#architecture)
-- [Troubleshooting](#troubleshooting)
-- [Contributing](#contributing)
-- [Changelog](#changelog)
-- [Notes](#notes)
-
-
-## Overview
-
-This demo application serves as a reference implementation of the [Spring User Framework](https://github.com/devondragon/SpringUserFramework), showing how to integrate user management features into a real-world Spring Boot application. It includes a complete user interface built with Bootstrap, Thymeleaf templates, and JavaScript.
-
-The application implements an event management system where users can browse, register for, and manage events. This demonstrates how to build application-specific functionality on top of the user management framework.
-
-## Version Compatibility
-
-This demo application tracks the latest version of the Spring User Framework:
-
-| Demo App Version | Spring Boot | Spring User Framework | Java | Branch/Tag |
-|------------------|-------------|----------------------|------|------------|
-| main (current) | 4.0.x | 5.1.x | 21+ | `main` |
-| 1.0.0-springboot3 | 3.5.x | 3.5.x | 17+ | [`v1.0.0-springboot3`](https://github.com/devondragon/SpringUserFrameworkDemoApp/tree/v1.0.0-springboot3) |
-
-### Using Spring Boot 3.x?
-
-If you need to use Spring Boot 3.5.x with Java 17, use the tagged version:
+[](https://adoptium.net/temurin/releases/?version=21)
+
+A demo application for the [Spring User Framework](https://github.com/devondragon/SpringUserFramework). It
+runs the framework's user-management surface (registration with email verification, login, passkeys, MFA,
+OAuth2 and OIDC, password reset, profile editing, account deletion) behind a working Thymeleaf and Bootstrap
+UI, and adds a small event-management domain on top to show how application code builds on the framework's
+identity and authorization. The HTML, JavaScript, and configuration here are meant to be copied into your own
+application as a starting point.
+
+
+
+Documentation for this demo is in [docs/](docs); the framework's own documentation lives in
+[its repository](https://github.com/devondragon/SpringUserFramework/blob/main/README.md).
+
+## Version compatibility
+
+| Demo version | Spring Boot | Spring User Framework | Java | Branch or tag |
+| --- | --- | --- | --- | --- |
+| main | 4.1.x | 5.3.x | 21 | `main` |
+| 1.0.0-springboot3 | 3.5.x | 3.5.x | 17 | [`v1.0.0-springboot3`](https://github.com/devondragon/SpringUserFrameworkDemoApp/tree/v1.0.0-springboot3) |
+
+`main` is on Spring Boot 4.1.0 and framework 5.3.0 ([build.gradle](build.gradle)). For the Spring Boot 3.5.6
+and framework 3.5.1 combination on Java 17, `git checkout v1.0.0-springboot3` after cloning.
+
+## What this demo shows
+
+| Capability | Where it lives | Details |
+| --- | --- | --- |
+| Application-specific user profile sharing the framework user's key | [`user/profile/`](src/main/java/com/digitalsanctuary/spring/demo/user/profile) | [EXTENDING.md](docs/EXTENDING.md#custom-user-profile-stack) |
+| Cleaning up application data when an account is deleted | [`UserProfileDeletionListener`](src/main/java/com/digitalsanctuary/spring/demo/user/profile/UserProfileDeletionListener.java) | [EXTENDING.md](docs/EXTENDING.md#cleaning-up-application-data-when-a-user-is-deleted) |
+| An application domain (events) using privilege-based access control | [`event/`](src/main/java/com/digitalsanctuary/spring/demo/event), roles in [`application.yml`](src/main/resources/application.yml) | [EXTENDING.md](docs/EXTENDING.md#building-your-own-domain-on-the-framework-events) |
+| Allowing or denying registrations through the `RegistrationGuard` SPI | [`DomainRegistrationGuard`](src/main/java/com/digitalsanctuary/spring/demo/registration/DomainRegistrationGuard.java), `registration-guard` profile | [AUTHENTICATION.md](docs/AUTHENTICATION.md#registration-guard) |
+| Passkey sign-in, enrollment, management, and passwordless registration | [`static/js/user/`](src/main/resources/static/js/user) (`webauthn-*.js`) | [AUTHENTICATION.md](docs/AUTHENTICATION.md#passkeys) |
+| Two-factor login, password plus passkey | [`application-mfa.yml`](src/main/resources/application-mfa.yml), [`user/mfa/`](src/main/resources/templates/user/mfa) | [AUTHENTICATION.md](docs/AUTHENTICATION.md#mfa) |
+| OAuth2 login with Google and Facebook | [`application-local.yml-example`](src/main/resources/application-local.yml-example) | [AUTHENTICATION.md](docs/AUTHENTICATION.md#oauth2-with-google-and-facebook) |
+| OIDC login against a bundled Keycloak, as a runnable stack | [`docker-compose-keycloak.yml`](docker-compose-keycloak.yml), [`keycloak/`](keycloak) | [AUTHENTICATION.md](docs/AUTHENTICATION.md#keycloak) |
+| Remember-me cookies | [`login.html`](src/main/resources/templates/user/login.html), [`application.yml`](src/main/resources/application.yml) | [AUTHENTICATION.md](docs/AUTHENTICATION.md#remember-me) |
+| Admin page and API that lock and unlock accounts | [`AdminController`](src/main/java/com/digitalsanctuary/spring/demo/controller/AdminController.java), [`AdminAPIController`](src/main/java/com/digitalsanctuary/spring/demo/controller/AdminAPIController.java) | [AUTHENTICATION.md](docs/AUTHENTICATION.md#admin) |
+| Reference templates, JavaScript, and message bundle to copy | [`templates/`](src/main/resources/templates), [`static/js/`](src/main/resources/static/js) | [EXTENDING.md](docs/EXTENDING.md#reference-templates-javascript-and-messages) |
+| Replacing a framework service with your own | [`CustomUserEmailService`](src/main/java/com/digitalsanctuary/spring/demo/service/CustomUserEmailService.java) | [EXTENDING.md](docs/EXTENDING.md#overriding-a-framework-service) |
+| A test-only API, profile-gated and loopback-only, for E2E runs | [`TestDataController`](src/main/java/com/digitalsanctuary/spring/demo/test/api/TestDataController.java) | [EXTENDING.md](docs/EXTENDING.md#profile-gated-test-only-endpoints) |
+| Browser tests covering the flows above | [`playwright/`](playwright) | [TESTING.md](docs/TESTING.md#playwright-e2e-tests) |
+
+## Project layout
+
+```
+src/main/java/com/digitalsanctuary/spring/demo/
+├── controller/ AdminController, AdminAPIController, PageController
+├── event/ the example domain: entity, repository, service, page and API controllers
+├── registration/ DomainRegistrationGuard, the RegistrationGuard SPI sample
+├── service/ CustomUserEmailService, a framework service replaced with @Primary
+├── test/ the test-only API and its security config, playwright-test profile only
+├── user/profile/ the profile entity, repository, service, session holder, and listeners
+├── util/ LocaleConfiguration
+└── web/ DemoTemplateModelAdvice
+src/main/resources/
+├── static/js/ user/ (one module per page), admin/, utils/, shared.js
+├── templates/ layout.html plus fragments/, user/, event/, admin/, mail/
+├── application.yml base configuration, with one application-.yml per profile
+└── data-local.sql sample events, loaded under the local profile
+src/test/java/com/digitalsanctuary/spring/
+├── demo/ tests for this application's own code
+└── user/ tests against the framework's user-management surface
+playwright/ E2E specs, fixtures, and playwright.config.ts
+keycloak/ realm export and TLS material for the Keycloak stack
+```
+
+## Quick start
+
+### Docker
+
+Nothing to install but Docker:
```bash
git clone https://github.com/devondragon/SpringUserFrameworkDemoApp.git
cd SpringUserFrameworkDemoApp
-git checkout v1.0.0-springboot3
-```
-
-This version uses:
-- Spring Boot 3.5.6
-- Spring User Framework 3.5.1
-- Java 17+
-
-## Features
-
-- **User Management**
- - Registration with email verification
- - Login/logout functionality
- - Password reset workflow
- - User profile management
- - Account deletion/disabling
-
-- **Authentication & Security**
- - Username/password authentication
- - WebAuthn/Passkey passwordless login (biometrics, security keys)
- - Passkey management (register, rename, delete)
- - OAuth2 login with Google, Facebook, and Keycloak
- - Multi-factor authentication (PASSWORD + WEBAUTHN passkey) via the `mfa` profile
- - Pluggable registration restrictions via the `RegistrationGuard` SPI (sample domain guard)
- - Role-based access control
- - CSRF protection
- - Security audit logging
-
-- **Application-Specific Features**
- - Custom user profile with additional fields
- - Event listing and management
- - User-to-event registration
- - Role-based permissions for events
-
-- **Technical Features**
- - Spring Boot auto-configuration
- - Thymeleaf templating with fragments
- - REST API with JSON responses
- - Responsive Bootstrap UI
- - Docker integration
-
-## Prerequisites
-
-Before you begin, ensure you have the following installed:
-
-- **Java**: JDK 21 or higher ([Download](https://www.oracle.com/java/technologies/downloads/)) - *Note: For Spring Boot 3.x version, Java 17+ is sufficient*
-- **Database**: MariaDB, MySQL, or Docker for containerized database
-- **Build Tool**: Gradle (included via wrapper) or Maven
-- **Optional**: Docker and Docker Compose for containerized setup
-- **Git**: For cloning the repository
-
-### System Requirements
-- **Memory**: Minimum 2GB RAM (4GB recommended)
-- **Disk Space**: At least 1GB free space
-- **Network**: Internet connection for downloading dependencies
-
-## Quick Start
-
-### 🚀 Zero to Running in 5 Minutes (Docker)
-
-The fastest way to get started is using Docker Compose:
-
-```bash
-# Clone and start everything
-git clone https://github.com/devondragon/SpringUserFrameworkDemoApp.git
-cd SpringUserFrameworkDemoApp
docker compose up --build
```
-**Access the Application**: `http://localhost:8080`
-
-### Manual Setup
-
-1. **Clone the repository**
- ```bash
- git clone https://github.com/devondragon/SpringUserFrameworkDemoApp.git
- cd SpringUserFrameworkDemoApp
- ```
-
-2. **Set up the database** (using Docker)
- ```bash
- docker run -d --name springuser-db \
- -e MYSQL_ROOT_PASSWORD=root \
- -e MYSQL_DATABASE=springuser \
- -e MYSQL_USER=springuser \
- -e MYSQL_PASSWORD=springuser \
- -p 3306:3306 \
- mariadb:latest
- ```
-
-3. **Configure the application**
- Copy the example configuration:
- ```bash
- cp src/main/resources/application-local.yml-example src/main/resources/application-local.yml
- ```
-
- (Optional for Keycloak) Copy the Keycloak configuration:
- ```bash
- cp src/main/resources/application-docker-keycloak.yml-example src/main/resources/application-docker-keycloak.yml
- ```
- Then edit the copied file as needed.
-
-4. **Run the application**
-
- Choose one of the following:
- - Using Gradle:
- ```bash
- ./gradlew bootRun
- ```
- - Using Maven:
- ```bash
- mvn spring-boot:run
- ```
- - Using Docker Compose with Keycloak stack:
- ```bash
- docker compose -f docker-compose-keycloak.yml up --build
- ```
-
-5. **Access the Application**
- Open your browser and navigate to:
- `http://localhost:8080`
-
-6. **Access Keycloak if enabled in Docker compose stack**
- Open your browser and navigate to:
- `https://localhost:8443`
-
-### First Time Setup
-
-After starting the application, you can:
-- Register a new account at `http://localhost:8080/user/register`
-- Use the demo data that may be pre-loaded
-- Check logs for any setup issues in the console output
-
----
+The app image is built from source inside Docker, so the first build takes several minutes. When it is up,
+open http://localhost:8080 and register at http://localhost:8080/user/register.html. This stack sets
+`USER_REGISTRATION_SENDVERIFICATIONEMAIL=false`, so accounts are enabled at registration and you can log in
+immediately. Its `mailserver` container is a relay with no route to real inboxes, so nothing it accepts will
+reach an actual mailbox. The stack runs under the `dev` profile and loads no sample events.
-## Testing
+Stop it with Ctrl-C, then `docker compose down -v` to remove the containers and their data.
-This project includes comprehensive testing with multiple approaches:
+### Locally with Gradle
-### Running Tests
+Needs JDK 21 ([mise.toml](mise.toml) pins it) and a running Docker daemon: `bootRun` starts the MariaDB
+container defined in [compose.dev.yaml](compose.dev.yaml) and stops it with the app.
```bash
-# Run all tests
-./gradlew test
-
-# Run specific test class
-./gradlew test --tests UserApiTest
-
-# Run specific test method
-./gradlew test --tests UserApiTest.testUserRegistration
-```
-
-### Test Categories
-
-- **Unit Tests**: Fast tests for individual components
-- **Integration Tests**: Tests using `@IntegrationTest` with Spring context
-- **API Tests**: REST endpoint testing with MockMvc
-- **UI Tests**: End-to-end testing with Playwright
-- **Security Tests**: Authentication and authorization testing
-
-### Test Data
-
-Test data builders are available in `src/test/java/com/digitalsanctuary/spring/demo/test/data/` for consistent test data creation.
-
-### Test Profiles
-
-Tests run with the `test` profile using H2 in-memory database for isolation.
-
----
-
-## API Documentation
-
-The application provides REST API endpoints for user management and event operations:
-
-### User Management API
-
-| Endpoint | Method | Description | Authentication |
-| ------------------ | ------ | --------------- | -------------- |
-| `/api/users` | GET | List all users | Admin |
-| `/api/users/{id}` | GET | Get user by ID | User/Admin |
-| `/api/users` | POST | Create new user | Public |
-| `/api/users/{id}` | PUT | Update user | User/Admin |
-| `/api/users/{id}` | DELETE | Delete user | User/Admin |
-| `/api/auth/login` | POST | User login | Public |
-| `/api/auth/logout` | POST | User logout | Authenticated |
-
-### Event Management API
-
-| Endpoint | Method | Description | Authentication |
-| --------------------------- | ------ | ------------------ | -------------- |
-| `/api/events` | GET | List events | Public |
-| `/api/events/{id}` | GET | Get event details | Public |
-| `/api/events` | POST | Create event | Admin |
-| `/api/events/{id}/register` | POST | Register for event | User |
-
-### Response Format
-
-All API endpoints return JSON responses:
-
-```json
-{
- "success": true,
- "data": { ... },
- "message": "Operation successful",
- "errors": []
-}
-```
-
-For detailed API documentation, start the application and visit `/swagger-ui.html` (if Swagger is enabled).
-
----
-
-## Project Structure
-
-```
-└── src/
- ├── main/
- │ ├── java/
- │ │ └── com/digitalsanctuary/spring/demo/
- │ │ ├── controller/ # Page controllers
- │ │ ├── event/ # Event-related functionality
- │ │ ├── user/
- │ │ │ └── profile/ # User profile extensions
- │ │ └── util/ # Utility classes
- │ └── resources/
- │ ├── static/ # Static resources (CSS, JS)
- │ ├── templates/ # Thymeleaf templates
- │ │ ├── fragments/ # Reusable template fragments
- │ │ ├── mail/ # Email templates
- │ │ └── user/ # User management templates
- │ └── application.yml # Application configuration
- └── test/ # Test classes
-```
-
-
-## Configuration
-
-### Configuration Profiles
-
-The application supports multiple configuration profiles:
-
-| Profile | Purpose | Database | Use Case |
-| ------------------- | ----------------------------- | ------------------ | ------------------------------------------------- |
-| `local` | Local development | MariaDB/MySQL | Development with persistent database |
-| `test` | Testing | H2 (in-memory) | Automated testing |
-| `dev` | Development server | MariaDB/MySQL | Shared development environment |
-| `docker-keycloak` | Docker with Keycloak | MariaDB + Keycloak | OIDC authentication testing |
-| `mfa` | Multi-factor authentication | (combine w/ above) | Require PASSWORD + WEBAUTHN; e.g. `local,mfa` |
-| `registration-guard`| Restricted registration | (combine w/ above) | Domain-restricted sign-up demo; e.g. `local,registration-guard` |
-
-> `mfa` and `registration-guard` are *opt-in add-on* profiles — activate them alongside a base profile (e.g. `--spring.profiles.active=local,registration-guard`). See [Registration Guard (restricting who can register)](#registration-guard-restricting-who-can-register) below.
-
-### Quick Configuration Setup
-
-1. **Copy example configurations:**
- ```bash
- cp src/main/resources/application-local.yml-example src/main/resources/application-local.yml
- cp src/main/resources/application-docker-keycloak.yml-example src/main/resources/application-docker-keycloak.yml
- ```
-
-2. **Edit configuration files** to match your environment
-3. **Set active profile:** `--spring.profiles.active=local`
-
-### Essential Configuration Settings
-
-#### **Database Configuration**
-The demo uses MariaDB as the default database. You can quickly spin up a MariaDB instance using Docker:
-```bash
-docker run -p 127.0.0.1:3306:3306 --name springuserframework \
- -e MARIADB_ROOT_PASSWORD=springuserroot \
- -e MARIADB_DATABASE=springuser \
- -e MARIADB_USER=springuser \
- -e MARIADB_PASSWORD=springuser \
- -d mariadb:latest
-```
-
-If you're running the application in a production-like environment, ensure you set the appropriate database properties in `application.yml` or your active profile.
-
----
-
-### Registration Guard (restricting who can register)
-
-The Spring User Framework exposes a `RegistrationGuard` SPI that lets a consuming app allow or deny each registration attempt — useful for invite-code gating, email allowlists, or domain restrictions. The framework calls every `RegistrationGuard` bean for form, passwordless, and OAuth2/OIDC sign-ups; if any guard denies, registration is rejected with the guard's message.
-
-This demo ships a sample implementation, [`DomainRegistrationGuard`](src/main/java/com/digitalsanctuary/spring/demo/registration/DomainRegistrationGuard.java), that restricts **form and passwordless** registration to a single email domain while allowing **all OAuth2/OIDC** registrations. It is gated behind the `registration-guard` Spring profile so the default demo experience is unaffected.
-
-**Try it:**
-
-```bash
-# Only @example.com email addresses can register via the form (OAuth2/OIDC still allowed)
-./gradlew bootRun --args='--spring.profiles.active=local,registration-guard'
-
-# Override the allowed domain — pass it inside --args as a Spring Boot argument so it reaches the
-# forked application (a -D after the task sets it on the Gradle JVM only and is not forwarded)
-./gradlew bootRun \
- --args='--spring.profiles.active=local,registration-guard --registration.guard.allowed-domain=@mycompany.com'
-```
-
-| Setting | Default | Purpose |
-| ------- | ------- | ------- |
-| `registration-guard` profile | off | Activates the sample guard bean |
-| `registration.guard.allowed-domain` | `@example.com` | Domain that form/passwordless registrations must match |
-
-With the profile active, registering a non-matching email returns the friendly denial message `Registration is restricted to email addresses.`
-
-**Writing your own guard:** implement `RegistrationGuard` as a Spring bean and return `RegistrationDecision.allow()` or `RegistrationDecision.deny(reason)`. The `RegistrationContext` exposes the email, `RegistrationSource` (FORM / PASSWORDLESS / OAUTH2 / OIDC), and provider name so you can apply different rules per source:
-
-```java
-@Component
-public class InviteCodeGuard implements RegistrationGuard {
- @Override
- public RegistrationDecision evaluate(RegistrationContext context) {
- // e.g. look up an invite code carried on the request, check an allowlist, etc.
- return isInvited(context.email())
- ? RegistrationDecision.allow()
- : RegistrationDecision.deny("An invitation is required to register.");
- }
-}
-```
-
-Multiple guards compose — all must allow. See the framework's [Registration Guard documentation](https://github.com/devondragon/SpringUserFramework/blob/main/REGISTRATION-GUARD.md) for the full SPI reference.
-
----
-
-#### **Mail Sending (SMTP)**
-The application requires an SMTP server for sending emails (e.g., account verification and password reset). Update the SMTP settings in your configuration file:
-```yaml
-spring:
- mail:
- host: smtp.example.com
- port: 587
- username: your-username
- password: your-password
- properties:
- mail.smtp.auth: true
- mail.smtp.starttls.enable: true
-
-user:
- mail:
- fromAddress: noreply@yourdomain.com
-```
-
-For local testing, the Docker Compose configuration includes a mail server that captures all outgoing emails.
-
----
-
-#### **SSO OAuth2 with Google and Facebook**
-To enable SSO:
-1. Create OAuth credentials in Google and Facebook developer consoles.
-2. Update your `application.yml`:
- ```yaml
- spring:
- security:
- oauth2:
- client:
- registration:
- google:
- client-id: YOUR_GOOGLE_CLIENT_ID
- client-secret: YOUR_GOOGLE_CLIENT_SECRET
- redirect-uri: "{baseUrl}/login/oauth2/code/google"
- facebook:
- client-id: YOUR_FACEBOOK_CLIENT_ID
- client-secret: YOUR_FACEBOOK_CLIENT_SECRET
- redirect-uri: "{baseUrl}/login/oauth2/code/facebook"
- ```
-
-3. Use a tool like [ngrok](https://ngrok.com/) for local testing of OAuth callbacks:
- ```bash
- ngrok http 8080
- ```
-
-Then update your OAuth2 providers' callback URLs to use the ngrok domain.
-
----
-
-#### **WebAuthn / Passkeys**
-
-The demo app includes full WebAuthn/Passkey support for passwordless login. Users can register passkeys (biometrics, security keys) from their profile page and use them to log in without a password.
-
-**Configuration** (in `application.yml`):
-```yaml
-user:
- webauthn:
- enabled: true # Enable passkey support
- rpId: localhost # Must match your domain
- rpName: Spring User Framework Demo # Display name shown during registration
- allowedOrigins: http://localhost:8080 # Must match browser origin exactly
-```
-
-**Important**: You must also add the WebAuthn endpoints to your unprotected URIs:
-```yaml
-user:
- security:
- unprotectedURIs: ...,/webauthn/authenticate/**,/login/webauthn
-```
-
-**How it works:**
-- **Register a passkey**: Log in with username/password, go to your profile page, and click "Add Passkey"
-- **Log in with passkey**: On the login page, click the "Sign in with a Passkey" button
-- **Manage passkeys**: From your profile page, rename or delete registered passkeys
-
-**Development notes:**
-- HTTP works on `localhost` without HTTPS
-- For testing on other devices, use ngrok (`ngrok http 8080`) and update `rpId` and `allowedOrigins` to match the ngrok domain
-- The database tables (`user_entities`, `user_credentials`) are created automatically by Hibernate
-
-### Environment Variables
-
-For production deployments, use environment variables instead of hardcoding values:
-
-```bash
-# Database
-export SPRING_DATASOURCE_URL=jdbc:mariadb://localhost:3306/springuser
-export SPRING_DATASOURCE_USERNAME=springuser
-export SPRING_DATASOURCE_PASSWORD=springuser
-
-# Mail
-export SPRING_MAIL_HOST=smtp.gmail.com
-export SPRING_MAIL_USERNAME=your-email@gmail.com
-export SPRING_MAIL_PASSWORD=your-app-password
-
-# OAuth2
-export GOOGLE_CLIENT_ID=your-google-client-id
-export GOOGLE_CLIENT_SECRET=your-google-client-secret
-export FACEBOOK_CLIENT_ID=your-facebook-client-id
-export FACEBOOK_CLIENT_SECRET=your-facebook-client-secret
-
-# Security
-export SPRING_SECURITY_BCRYPT_STRENGTH=12
-export SPRING_SECURITY_FAILED_LOGIN_ATTEMPTS=5
-
-# Remember-me token signing key (required by the prd profile; startup fails without it)
-export REMEMBER_ME_KEY=a-long-random-value-from-your-secret-manager
-```
-
-### Important Security Settings
-
-- **BCrypt Strength**: Set to `12` or higher for production
-- **Session Timeout**: Default `30m`, adjust based on security requirements
-- **Account Lockout**: Configure failed login attempts and lockout duration
-- **CSRF Protection**: Enabled by default, ensure proper configuration for APIs
-
-### Framework-Specific Configuration
-
-See [CONFIG.md](CONFIG.md) for detailed framework configuration options or refer to the [Spring User Framework documentation](https://github.com/devondragon/SpringUserFramework) for complete configuration reference.
-
-
-
-
-#### **SSO OIDC with Keycloak**
-To enable SSO:
-1. Create OIDC client in Keycloak admin console.
-2. Update your `application-docker-keycloak.yml`:
- ```yaml
- spring:
- security:
- oauth2:
- client:
- registration:
- keycloak:
- client-id: ${DS_SPRING_USER_KEYCLOAK_CLIENT_ID} # Keycloak client ID for OAuth2
- client-secret: ${DS_SPRING_USER_KEYCLOAK_CLIENT_SECRET} # Keycloak client secret for OAuth2
- authorization-grant-type: authorization_code # Authorization grant type for OAuth2
- scope:
- - email # Request email scope for OAuth2
- - profile # Request profile scope for OAuth2
- - openid # Request oidc scope for OAuth2
- client-name: Keycloak # Name of the OAuth2 client
- provider: keycloak
- provider:
- keycloak: # https://www.keycloak.org/securing-apps/oidc-layers
- issuer-uri: ${DS_SPRING_USER_KEYCLOAK_PROVIDER_ISSUER_URI}
- authorization-uri: ${DS_SPRING_USER_KEYCLOAK_PROVIDER_AUTHORIZATION_URI}
- token-uri: ${DS_SPRING_USER_KEYCLOAK_PROVIDER_TOKEN_URI}
- user-info-uri: ${DS_SPRING_USER_KEYCLOAK_PROVIDER_USER_INFO_URI}
- user-name-attribute: preferred_username # https://www.keycloak.org/docs-api/latest/rest-api/index.html#UserRepresentation
- jwk-set-uri: ${DS_SPRING_USER_KEYCLOAK_PROVIDER_JWK_SET_URI}
- ```
-3. Refer to `keycloak.env` for default values for the above environment variables
-4. You can directly start with Keycloak using the default realm provided in this project under `keycloak/realm/realm-export.json` that comes pre configured with a OIDC client and secret for this application Keycloak
-
----
-
-
-## Running the Application
-
-### Running Locally
-
-#### Using Gradle
-```bash
-./gradlew bootRun
-```
-
-#### Using Maven
-```bash
-mvn spring-boot:run
-```
-
-#### With specific profile
-```bash
-./gradlew bootRun --args='--spring.profiles.active=dev'
-```
-
-### Running with Docker
-
-The project includes a complete Docker setup with the application, MariaDB database, and a mail server.
-
-**Docker Compose files:**
-- **`compose.yaml`** — Full deployable stack (app + database + mail server). Use this to run the entire application in Docker.
-- **`compose.dev.yaml`** — Dev dependencies only (database). Used automatically by Spring Boot's Docker Compose integration during `bootRun` for local development.
-- **`docker-compose-keycloak.yml`** — Full stack with Keycloak for OIDC authentication testing.
-
-```bash
-docker compose up --build
-```
-
-To launch the Keycloak stack:
-```bash
-docker compose -f docker-compose-keycloak.yml up --build
-```
-
-**Note**: Test emails sent from the local Postfix server may not be accepted by all email providers. Use a real SMTP server for production use.
-
----
-
-## Development Tools
-
-### IDE Setup
-
-**IntelliJ IDEA (Recommended):**
-```bash
-# Import as Gradle project
-# Enable annotation processing: Settings > Build > Compiler > Annotation Processors
-# Install Lombok plugin if needed
-```
-
-**VS Code:**
-```bash
-# Install extensions:
-# - Extension Pack for Java
-# - Spring Boot Extension Pack
-# - Gradle for Java
-```
-
-### Common Development Tasks
-
-```bash
-# Quick development startup
+git clone https://github.com/devondragon/SpringUserFrameworkDemoApp.git
+cd SpringUserFrameworkDemoApp
+cp src/main/resources/application-local.yml-example src/main/resources/application-local.yml
./gradlew bootRun --args='--spring.profiles.active=local'
-
-# Debug mode (port 5005)
-./gradlew bootRun --debug-jvm
-
-# Build and run with custom script
-./scripts/run.sh
-
-# Hot reload with DevTools (automatic)
-# Just save files and changes will be picked up
-
-# Check for security vulnerabilities
-./gradlew dependencyCheckAnalyze
-
-# Generate test reports
-./gradlew test jacocoTestReport
-```
-
-### Performance and Monitoring
-
-- **Application Metrics**: `/actuator/metrics`
-- **Health Check**: `/actuator/health`
-- **Database Console**: `/h2-console` (when using H2)
-- **Log Levels**: Configure in `application.yml` or via `/actuator/loggers`
-
-### Debugging Tips
-
-1. **Database Issues**: Enable SQL logging with `spring.jpa.show-sql=true`
-2. **Authentication Problems**: Enable security debug logging
-3. **Email Issues**: Check `logs/audit.log` for user events
-4. **Performance**: Use `/actuator/httptrace` to monitor requests
-
-### Spring Boot DevTools
-This project supports **Spring Boot DevTools** for live reload and auto-restart. If you are working with HTTPS locally, follow these steps to enable live reload:
-1. Set the following property in `application.yml`:
- ```yaml
- spring.devtools.livereload.https=true
- ```
-
- Or when using Keycloak stack set the following property in `application-docker-keycloak.yml`:
- ```yaml
- spring.devtools.livereload.https=true
- ```
-
-2. Use a reverse proxy like mitmproxy for HTTPS traffic interception:
- ```bash
- mitmproxy --mode reverse:http://localhost:35729 -p 35739
- ```
-
-#### Resources for Live Reload:
-- [Spring Boot Live Reload](https://www.digitalsanctuary.com/java/springboot-devtools-auto-restart-and-live-reload.html)
-- [HTTPS Live Reload Setup](https://www.digitalsanctuary.com/java/how-to-get-springboot-livereload-working-over-https.html)
-
----
-
-## Architecture
-
-### System Overview
-
-```
-┌─────────────────┐ ┌──────────────────┐ ┌─────────────────┐
-│ Web Browser │ │ Load Balancer │ │ Application │
-│ │◄──►│ (Optional) │◄──►│ Spring Boot │
-└─────────────────┘ └──────────────────┘ └─────────────────┘
- │
- ▼
-┌─────────────────┐ ┌──────────────────┐ ┌─────────────────┐
-│ OAuth2 Providers│ │ Email Service │ │ Database │
-│ Google/Facebook │◄──►│ SMTP │◄──►│ MariaDB/MySQL │
-│ /Keycloak │ │ │ │ │
-└─────────────────┘ └──────────────────┘ └─────────────────┘
-```
-
-### Key Architectural Patterns
-
-1. **MVC Pattern**: Controllers handle HTTP requests, delegate to services
-2. **Service Layer**: Business logic separation with framework extension
-3. **Repository Pattern**: Data access abstraction through Spring Data JPA
-4. **Event-Driven**: Application events for user lifecycle management
-5. **Security Layered**: Spring Security with multiple authentication methods
-
-### Technology Stack
-
-| Layer | Technology | Purpose |
-| -------------- | --------------------------- | ---------------------------------------------- |
-| **Frontend** | Thymeleaf + Bootstrap | Server-side rendering with responsive UI |
-| **Backend** | Spring Boot 4.0+ | Application framework and dependency injection |
-| **Security** | Spring Security 7 | Authentication, authorization, CSRF protection |
-| **Data** | Spring Data JPA + Hibernate | Object-relational mapping and data access |
-| **Database** | MariaDB/MySQL | Primary data persistence |
-| **Testing** | JUnit 5 + Playwright | Unit, integration, and UI testing |
-| **Build** | Gradle | Dependency management and build automation |
-| **Containers** | Docker + Docker Compose | Development and deployment |
-
----
-
-## Troubleshooting
-
-### Common Issues and Solutions
-
-#### Database Connection Issues
-**Problem**: `Connection refused` or `Access denied`
-```
-Solution:
-1. Verify database is running: docker ps
-2. Check credentials in application-local.yml
-3. Ensure database exists: SHOW DATABASES;
-4. Check firewall/network connectivity
-```
-
-#### Build Failures
-**Problem**: `Could not resolve dependencies`
-```
-Solution:
-1. ./gradlew clean build --refresh-dependencies
-2. Check internet connection
-3. Verify Java version: java -version (requires JDK 21+)
-4. Clear Gradle cache: rm -rf ~/.gradle/caches
-```
-
-#### OAuth2/OIDC Issues
-**Problem**: OAuth2 login fails or redirects incorrectly
-```
-Solution:
-1. Verify OAuth2 client credentials in application.yml
-2. Check redirect URI configuration in OAuth provider
-3. Use ngrok for local HTTPS testing
-4. Verify Keycloak realm and client settings
```
-#### WebAuthn/Passkey Issues
-**Problem**: Passkey registration or login fails
-```
-Solution:
-1. Verify user.webauthn.enabled is true in application.yml
-2. Check that rpId matches your domain (localhost for local dev)
-3. Ensure allowedOrigins matches the exact browser URL (including port)
-4. Verify /webauthn/authenticate/** and /login/webauthn are in unprotectedURIs
-5. For non-localhost testing, HTTPS is required - use ngrok
-6. Check browser console for WebAuthn API errors
-```
+`application-local.yml` is gitignored, so credentials you put in it stay out of git. Copying it also turns off
+the verification email and loads the sample events in
+[`data-local.sql`](src/main/resources/data-local.sql). Then open http://localhost:8080, register at
+http://localhost:8080/user/register.html, and browse the API at http://localhost:8080/swagger-ui.html.
+
+To use a database you manage yourself instead of the container, set `spring.docker.compose.enabled: false` and
+your own `spring.datasource.*` values; see [CONFIGURATION.md](docs/CONFIGURATION.md).
+
+### With Keycloak
+
+`docker compose -f docker-compose-keycloak.yml up -d --build --wait` runs the app against a bundled Keycloak
+and its imported `demo` realm. Ports, credentials, and the login walkthrough are in
+[keycloak/README.md](keycloak/README.md) and [AUTHENTICATION.md](docs/AUTHENTICATION.md#keycloak).
+
+## Profiles
+
+| Profile | What it is for |
+| --- | --- |
+| `local` | Everyday local development; needs `application-local.yml` copied from the example |
+| `dev` | Debug-heavy dev server; what the `compose.yaml` Docker stack runs |
+| `prd` | Production settings: env-driven datasource and URLs, strict cookies, template caching |
+| `test` | The JUnit suite on H2, applied automatically by `./gradlew test` |
+| `playwright-test` | Add-on: enables the loopback-only test API and disables outbound mail for E2E runs |
+| `docker-keycloak` | OIDC against the bundled Keycloak; set for you inside `docker-compose-keycloak.yml` |
+| `mfa` | Add-on: requires PASSWORD plus WEBAUTHN, for example `local,mfa` |
+| `registration-guard` | Add-on: activates the domain-restricted registration guard, for example `local,registration-guard` |
+
+Pick a base profile with `--spring.profiles.active=`, and list add-ons after it, comma-separated. With no
+`--args` at all, `bootRun` still runs `local` ([build.gradle:114-124](build.gradle)). Full per-profile
+override lists are in [CONFIGURATION.md](docs/CONFIGURATION.md).
+
+## Documentation
+
+This demo:
+
+- [docs/CONFIGURATION.md](docs/CONFIGURATION.md): profiles, the properties this demo sets, environment
+ variables, mail, and security settings.
+- [docs/DEVELOPMENT.md](docs/DEVELOPMENT.md): prerequisites, running the app, the Compose files, Gradle
+ tasks, logs, LiveReload, and IDE setup.
+- [docs/TESTING.md](docs/TESTING.md): the JUnit suite, disabled tests, the Playwright suite and its test
+ API, and CI.
+- [docs/EXTENDING.md](docs/EXTENDING.md): each framework extension point, the demo code that uses it, and
+ what to write in your own app.
+- [docs/AUTHENTICATION.md](docs/AUTHENTICATION.md): every authentication path here, how to run it, and its
+ configuration.
+- [keycloak/README.md](keycloak/README.md): the Keycloak stack, its realm export, and its credentials.
+- [CHANGELOG.md](CHANGELOG.md): what changed, by date.
+
+The framework:
+
+- [README.md](https://github.com/devondragon/SpringUserFramework/blob/main/README.md): what the library does
+ and how to add it to an application.
+- [CONFIG.md](https://github.com/devondragon/SpringUserFramework/blob/main/CONFIG.md): the full property
+ reference.
+- [MIGRATION.md](https://github.com/devondragon/SpringUserFramework/blob/main/MIGRATION.md): upgrading
+ between framework versions.
+- [docs/PROFILE.md](https://github.com/devondragon/SpringUserFramework/blob/main/docs/PROFILE.md): the
+ user-profile extension contract.
+- [docs/REGISTRATION-GUARD.md](https://github.com/devondragon/SpringUserFramework/blob/main/docs/REGISTRATION-GUARD.md):
+ the `RegistrationGuard` SPI.
-#### Email Not Sending
-**Problem**: Registration emails not received
-```
-Solution:
-1. Check SMTP configuration in application.yml
-2. Verify mail server credentials
-3. Check spam/junk folders
-4. Use Docker mail server for testing: docker compose logs mailserver
-```
-
-#### Application Won't Start
-**Problem**: Port conflicts or configuration errors
-```
-Solution:
-1. Check if port 8080 is in use: lsof -i :8080
-2. Change server.port in application.yml
-3. Review application logs for configuration errors
-4. Verify all required environment variables are set
-```
-
-### Getting Help
-
-- **Logs**: Check console output and log files in `logs/` directory
-- **Health Check**: Visit `/actuator/health` when application is running
-- **Documentation**: Review [Spring User Framework docs](https://github.com/devondragon/SpringUserFramework)
-- **Issues**: Report bugs on [GitHub Issues](https://github.com/devondragon/SpringUserFrameworkDemoApp/issues)
-
----
-
-## Contributing
-
-We welcome contributions! Here's how to get started:
-
-### Development Setup
-
-1. Fork the repository
-2. Create a feature branch: `git checkout -b feature/amazing-feature`
-3. Set up development environment following the Quick Start guide
-4. Make your changes following the existing code patterns
-
-### Code Standards
-
-- Follow existing code formatting and conventions
-- Write tests for new functionality
-- Update documentation as needed
-- Ensure all tests pass: `./gradlew test`
-
-### Submitting Changes
-
-1. Commit your changes: `git commit -m "Add amazing feature"`
-2. Push to your fork: `git push origin feature/amazing-feature`
-3. Create a Pull Request with description of changes
-
-### Development Commands
+## Testing
```bash
-# Run with auto-restart
-./gradlew bootRun
-
-# Run specific test profile
-./gradlew bootRun --args='--spring.profiles.active=test'
-
-# Check for dependency updates
-./gradlew dependencyUpdates
-
-# Build without tests (faster)
-./gradlew build -x test
+./gradlew test # JUnit suite, test profile, H2
+cd playwright && npm ci && npx playwright install # once
+./gradlew playwrightTest # E2E, starts the app itself
```
----
+Details, including the MFA-only Playwright project and the test-only API, are in
+[docs/TESTING.md](docs/TESTING.md).
-## Changelog
+## Contributing
-See [CHANGELOG.md](CHANGELOG.md) for a full history of the project's evolution, including framework version updates, new features, and fixes.
+Issues and pull requests are welcome at
+[SpringUserFrameworkDemoApp](https://github.com/devondragon/SpringUserFrameworkDemoApp/issues). Follow the
+patterns already in the code, add tests for new behavior, and make sure `./gradlew test` passes before
+opening a pull request. Changes to the library itself belong in the framework repository, which has its own
+[CONTRIBUTING.md](https://github.com/devondragon/SpringUserFramework/blob/main/CONTRIBUTING.md).
----
+## License
-## Notes
+Apache License 2.0; see [LICENSE](LICENSE).
-- This demo is based on the principles outlined in the [Baeldung Spring Security Course](https://www.baeldung.com/learn-spring-security-course).
-- Feel free to customize and extend the provided functionality to suit your needs.
-**Disclaimer:** This is a demo project provided as-is with no guarantees of performance, security, or production readiness.
+The application is based on the principles in the
+[Baeldung Spring Security Course](https://www.baeldung.com/learn-spring-security-course).
+**Disclaimer:** This is a demo project provided as-is with no guarantees of performance, security, or
+production readiness.
From cd9ff9045a1ca84b1dcd6025cf14ee737be780a4 Mon Sep 17 00:00:00 2001
From: Devon Hillard
Date: Sat, 15 Aug 2026 01:58:16 -0600
Subject: [PATCH 21/26] docs: delete superseded CONFIG.md and docs/HELP.md,
correct CLAUDE.md facts
---
CLAUDE.md | 19 +++++++++----
CONFIG.md | 77 ----------------------------------------------------
docs/HELP.md | 39 --------------------------
3 files changed, 13 insertions(+), 122 deletions(-)
delete mode 100644 CONFIG.md
delete mode 100644 docs/HELP.md
diff --git a/CLAUDE.md b/CLAUDE.md
index b9d0ea9..a31af19 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -64,21 +64,28 @@ This is a Spring Boot demo application showcasing the [Spring User Framework](ht
2. **Configuration Profiles**:
- `local`: Development with local database
- - `test`: Integration testing with H2
+ - `dev`: Debug-heavy dev server; also what the `compose.yaml` Docker stack runs
+ - `prd`: Production settings (env-driven datasource and URLs, strict cookies)
+ - `test`: Integration testing with H2, applied automatically by `./gradlew test`
+ - `playwright-test`: Enables the test-only API for E2E runs; combine with a base profile
- `docker-keycloak`: OIDC integration with Keycloak
- `registration-guard`: Enables domain-restricted registration (form/passwordless only)
- `mfa`: Enables multi-factor authentication (PASSWORD + WEBAUTHN); combine with another profile, e.g. `local,mfa`
-3. **Template Organization**: All Thymeleaf templates are in `src/main/resources/templates/` with subdirectories for user management (`email/`, `password/`, etc.)
+3. **Template Organization**: All Thymeleaf templates are in `src/main/resources/templates/`, with subdirectories `user/` (including `user/mfa/`), `mail/`, `event/`, `admin/`, and `fragments/`
-4. **Test Data Builders**: Use the builder classes in `src/test/java/com/devondragon/springdemo/test/data/` for consistent test data creation.
+4. **Test Data Builders**: Use the builder classes in `src/test/java/com/digitalsanctuary/spring/user/test/builders/` for consistent test data creation.
### Framework Integration Points
The application demonstrates framework usage through:
-- Custom controllers that extend framework functionality (EventController)
-- Service extensions (CustomUserService extends UserService)
+- Custom controllers that build on framework functionality (`event/EventAPIController`, `event/EventPageController`)
+- Service extensions (`service/CustomUserEmailService` extends the framework's `UserEmailService` and is `@Primary`)
- Configuration of framework components via application.yml
- Event listeners for user lifecycle events
-When modifying user-related functionality, check if the Spring User Framework already provides it before implementing custom solutions.
\ No newline at end of file
+When modifying user-related functionality, check if the Spring User Framework already provides it before implementing custom solutions.
+
+## Documentation
+
+Demo documentation lives in `docs/`: `CONFIGURATION.md` (profiles and properties), `DEVELOPMENT.md` (running and building), `TESTING.md` (JUnit and Playwright), `EXTENDING.md` (framework extension points), `AUTHENTICATION.md` (every auth path). The Keycloak stack has its own `keycloak/README.md`. Update these rather than growing `README.md`.
\ No newline at end of file
diff --git a/CONFIG.md b/CONFIG.md
deleted file mode 100644
index 5817a81..0000000
--- a/CONFIG.md
+++ /dev/null
@@ -1,77 +0,0 @@
-# CONFIG.md
-
-Welcome to the User Framework SpringBoot Configuration Guide! This document outlines the key configuration values you'll need to set up and customize the framework for your specific needs. Configuration values which can generally be left as defaults are not included in this document. Please review the applicaiton.yml file for more information on all the available configuration values.
-
-## Essential Configuration
-
-### Mail Server Settings
-
-- **Username (`spring.mail.username`)**: Set this to your mail server's username.
-- **Password (`spring.mail.password`)**: Your mail server's password goes here.
-- **Host (`spring.mail.host`)**: Set this to your mail server's hostname
-- **Port (`spring.mail.port`)**: Set to `587` by default. Modify if your mail server uses a different port.
-
-### Database Configuration
-
-- **URL (`spring.datasource.url`)**: The JDBC URL for your database.
-- **Username (`spring.datasource.username`)**: Database username.
-- **Password (`spring.datasource.password`)**: Database password.
-- **Driver Class Name (`spring.datasource.driverClassName`)**: The JDBC driver, defaults to `org.mariadb.jdbc.Driver`.
-
-### Hibernate Settings
-
-- **DDL Auto (`spring.jpa.hibernate.ddl-auto`)**: Hibernate schema generation strategy, defaults to `update`.
-- **Dialect (`spring.jpa.properties.hibernate.dialect`)**: Set this to the appropriate dialect for your database, defaults to `org.hibernate.dialect.MariaDBDialect`.
-
-### Application Properties
-
-- **Name (`spring.application.name`)**: Set your application's name, defaults to `User Framework`.
-
-## User Settings
-
-- **Account Deletion (`user.actuallyDeleteAccount`)**: Set to `true` to enable account deletion. Defaults to `false` where accounts are disabled instead of deleted.
-- **Registration Email Verification (`user.registration.sendVerificationEmail`)**: Enable (`true`) or disable (`false`) sending verification emails post-registration.
-
-## Audit Logging
-
-- **Log File Path (`user.audit.logFilePath`)**: The path to the audit log file.
-- **Flush on Write (`user.audit.flushOnWrite`)**: Set to `true` for immediate log flushing. Defaults to `false` for performance.
-
-## Security Settings
-
-- **Failed Login Attempts (`user.security.failedLoginAttempts`)**: Number of failed login attempts before account lockout. Set to `0` to disable lockout.
-- **Account Lockout Duration (`user.security.accountLockoutDuration`)**: Duration (in minutes) for account lockout.
-- **BCrypt Strength (`user.security.bcryptStrength`)**: Adjust the bcrypt strength for password hashing. Default is `12`.
-- **Canonical App URL (`user.security.appUrl`)**: Canonical base URL for security email links (password reset, verification). Set this to prevent Host-header poisoning of those links (CWE-640); when it is unset the framework logs a startup warning and derives the host from the (spoofable) request `Host` header. This demo sets it per profile — `http://localhost:8080` for local/E2E, and an `${APP_URL}` env var in `prd`.
-- **Trusted Hosts (`user.security.trustedHosts`)**: Alternative to `appUrl` — a comma-separated allow-list of hosts honored for email links when `appUrl` is not set; a non-allow-listed request host falls back to the first trusted host.
-- **Require Canonical App URL (`user.security.requireCanonicalAppUrl`)**: When `true`, startup **fails** unless `appUrl` or a non-empty `trustedHosts` is configured (fail-fast instead of a warning). The `prd` profile enables this.
-- **Allow Initial Password Set Without Step-Up (`user.security.allowInitialPasswordSetWithoutStepUp`)**: Controls `POST /user/setPassword`, which lets a passwordless (passkey-only) account set an initial password. As of the framework's SUF-02 hardening this endpoint is **disabled by default** (returns `HTTP 403`) unless you provide a `StepUpService` bean or set this to `true`. This demo sets it `true` in the interactive profiles (`local`, `mfa`, `playwright-test`) so the passkey "set a password" flow works, and leaves it `false` (secure default) in `prd`.
-
-## Mail Configuration
-
-- **From Address (`user.mail.fromAddress`)**: The email address used as the sender in outgoing emails.
-
-## Copyright
-
-- **First Year (`spring.copyrightFirstYear`)**: The starting year for the copyright notice.
-
-## Role and Privileges
-
-- **Roles and Privileges (`spring.roles-and-privileges`)**: Map out roles to their respective privileges.
-- **Role Hierarchy (`spring.role-hierarchy`)**: Define the hierarchy and inheritance of roles.
-
-## New Relic Monitoring
-
-- **API Key and Account ID (`management.newrelic.metrics.export`)**: Required if you're integrating with New Relic for monitoring.
-
-## Server and Session Settings
-
-- **Session Timeout (`server.servlet.session.timeout`)**: The session timeout period, defaults to `30m` (30 minutes).
-
-## Logging
-
-- **Log File Path (`logging.file.name`)**: Set the path to the application log file.
-
----
-
-Remember, this guide covers the most critical settings to get you started. Depending on your specific use case, you may need to explore and adjust additional configurations. Always refer to the official SpringBoot and related libraries' documentation for more detailed information.
diff --git a/docs/HELP.md b/docs/HELP.md
deleted file mode 100644
index 08af8d0..0000000
--- a/docs/HELP.md
+++ /dev/null
@@ -1,39 +0,0 @@
-# Getting Started
-
-### Reference Documentation
-For further reference, please consider the following sections:
-
-* [Official Gradle documentation](https://docs.gradle.org)
-* [Spring Boot Gradle Plugin Reference Guide](https://docs.spring.io/spring-boot/docs/2.4.2/gradle-plugin/reference/html/)
-* [Create an OCI image](https://docs.spring.io/spring-boot/docs/2.4.2/gradle-plugin/reference/html/#build-image)
-* [Spring Boot DevTools](https://docs.spring.io/spring-boot/docs/2.4.2/reference/htmlsingle/#using-boot-devtools)
-* [Spring Configuration Processor](https://docs.spring.io/spring-boot/docs/2.4.2/reference/htmlsingle/#configuration-metadata-annotation-processor)
-* [Spring Web](https://docs.spring.io/spring-boot/docs/2.4.2/reference/htmlsingle/#boot-features-developing-web-applications)
-* [Thymeleaf](https://docs.spring.io/spring-boot/docs/2.4.2/reference/htmlsingle/#boot-features-spring-mvc-template-engines)
-* [Spring Security](https://docs.spring.io/spring-boot/docs/2.4.2/reference/htmlsingle/#boot-features-security)
-* [JDBC API](https://docs.spring.io/spring-boot/docs/2.4.2/reference/htmlsingle/#boot-features-sql)
-* [Spring Data JPA](https://docs.spring.io/spring-boot/docs/2.4.2/reference/htmlsingle/#boot-features-jpa-and-spring-data)
-* [Java Mail Sender](https://docs.spring.io/spring-boot/docs/2.4.2/reference/htmlsingle/#boot-features-email)
-* [Spring Boot Actuator](https://docs.spring.io/spring-boot/docs/2.4.2/reference/htmlsingle/#production-ready)
-* [New Relic](https://docs.spring.io/spring-boot/docs/2.4.2/reference/html/production-ready-features.html#production-ready-metrics-export-new-relic)
-
-### Guides
-The following guides illustrate how to use some features concretely:
-
-* [Building a RESTful Web Service](https://spring.io/guides/gs/rest-service/)
-* [Serving Web Content with Spring MVC](https://spring.io/guides/gs/serving-web-content/)
-* [Building REST services with Spring](https://spring.io/guides/tutorials/bookmarks/)
-* [Handling Form Submission](https://spring.io/guides/gs/handling-form-submission/)
-* [Securing a Web Application](https://spring.io/guides/gs/securing-web/)
-* [Spring Boot and OAuth2](https://spring.io/guides/tutorials/spring-boot-oauth2/)
-* [Authenticating a User with LDAP](https://spring.io/guides/gs/authenticating-ldap/)
-* [Accessing Relational Data using JDBC with Spring](https://spring.io/guides/gs/relational-data-access/)
-* [Managing Transactions](https://spring.io/guides/gs/managing-transactions/)
-* [Accessing Data with JPA](https://spring.io/guides/gs/accessing-data-jpa/)
-* [Building a RESTful Web Service with Spring Boot Actuator](https://spring.io/guides/gs/actuator-service/)
-
-### Additional Links
-These additional references should also help you:
-
-* [Gradle Build Scans – insights for your project's build](https://scans.gradle.com#gradle)
-
From 24006e5e782fc9a292af5e4103ea33e15b7cb2c0 Mon Sep 17 00:00:00 2001
From: Devon Hillard
Date: Sat, 15 Aug 2026 01:58:16 -0600
Subject: [PATCH 22/26] docs: add 2026-08-15 changelog entry
---
CHANGELOG.md | 49 +++++++++++++++++++++++++++++++++++++++++++++++++
1 file changed, 49 insertions(+)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index e36891f..cf2f6e3 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -9,6 +9,55 @@ the library.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
+## 2026-08-15
+
+### Added
+- Admin lock/unlock API (`POST /admin/lockAccount`, `POST /admin/unlockAccount`), guarded by
+ `ADMIN_PRIVILEGE` and backing the admin actions page, whose JavaScript already called those paths
+- `keycloak/README.md`: contents, ports, credentials, and realm re-export for the Keycloak stack
+- `.dockerignore`, so the image build context excludes `build/`, `.git/`, and local config
+
+### Changed
+- Rewrote `README.md` and split its content into `docs/CONFIGURATION.md`, `docs/DEVELOPMENT.md`,
+ `docs/TESTING.md`, `docs/EXTENDING.md`, `docs/AUTHENTICATION.md`, and `keycloak/README.md`
+- `Dockerfile` is now multi-stage on Java 21: a JDK stage runs `bootJar` inside the image, a JRE
+ stage runs it, so `docker compose up --build` works from a fresh clone with no local Gradle build
+- The Docker demo stack sets `USER_REGISTRATION_SENDVERIFICATIONEMAIL=false` in `compose.yaml`, so
+ registered accounts are enabled immediately instead of waiting on mail the relay cannot deliver
+- Moved `spring.docker.compose.file: compose.dev.yaml` from `application-local.yml-example` into base
+ `application.yml`, so `./gradlew bootRun` starts its database without a copied config file
+- `build.gradle` no longer extends `runtimeOnly` from `developmentOnly`, keeping
+ `spring-boot-docker-compose` out of the packaged jar (it made containerized runs fail at startup)
+- `application-local.yml-example` now points at the `compose.dev.yaml` database and seeds
+ `data-local.sql`, so the `local` profile has sample events
+- Added `/actuator/health` to `user.security.unprotectedURIs` for container healthchecks; the rest of
+ `/actuator` still requires a login
+- `src/main/resources/application-docker-keycloak.yml` is now tracked; it holds only environment
+ variable placeholders, so there is nothing to copy before running the Keycloak stack
+- `mise.toml` pins Java 21
+- `@Disabled` test annotations now point at `docs/TESTING.md`
+
+### Fixed
+- Keycloak OIDC stack, which could not complete a login: the realm export is now named `demo` rather
+ than `master` (Keycloak skips a `master` import), the client secret matches `keycloak.env`,
+ authorization services that blocked the import were removed, a `demo` user with an email address
+ was added, the browser-facing and container-facing Keycloak URLs are split, the client's redirect
+ URI is narrowed to the exact callback, and the healthcheck probes the realm's discovery document
+- `DemoSessionProfile` is now `@SessionScopedProfile`; as a plain `@Component` it was a singleton
+ shared by every HTTP session
+- `DomainRegistrationGuard`'s Javadoc link to the framework's registration guard documentation
+
+### Removed
+- Root `CONFIG.md` (a stale copy of the framework's property reference), `docs/HELP.md` (Spring
+ Initializr boilerplate), `docs/TEST-ANALYSIS.md` (absorbed into `docs/TESTING.md`), and the
+ `TempTest` startup debug logger
+
+### Dependencies
+- **Spring User Framework 5.3.0**, Spring Boot 4.1.0 (2026-08-14)
+- Backfill of the bumps between the entries: framework 4.3.1 (2026-03-22, missing from that entry),
+ 4.4.0, 5.0.0, and 5.0.1 (2026-06-15), 5.1.0 (2026-07-10), 5.1.1 (2026-07-24), 5.2.0 (2026-08-12);
+ Gradle wrapper 9.4.1 to 9.7.0 (2026-08-13). Spring Boot stayed on 4.0.4 until the 4.1.0 bump above
+
## 2026-03-22
### Changed
From dee5ae89c2b065c1ee324c1d4f46724b6f7b9aea Mon Sep 17 00:00:00 2001
From: Devon Hillard
Date: Sat, 15 Aug 2026 02:30:55 -0600
Subject: [PATCH 23/26] fix: comment out the broken keycloak block in
application-local.yml-example
The masked client-secret (*******) is not valid YAML, and Spring Boot resolves
the provider issuer-uri at startup against a host that does not exist, so
copying the example to application-local.yml killed the local quick start
before the context started. Both blocks are commented out with a pointer to
the Keycloak stack, which is where OIDC actually runs.
---
.../resources/application-local.yml-example | 41 ++++++++++---------
1 file changed, 22 insertions(+), 19 deletions(-)
diff --git a/src/main/resources/application-local.yml-example b/src/main/resources/application-local.yml-example
index 6da89d2..4153258 100644
--- a/src/main/resources/application-local.yml-example
+++ b/src/main/resources/application-local.yml-example
@@ -55,25 +55,28 @@ spring:
- email # Request email scope for OAuth2
- public_profile # Request public_profile scope for OAuth2
client-name: Facebook # Name of the OAuth2 client
- keycloak:
- client-id: ds-spring-user-framework-demo # Keycloak client ID for OAuth2
- client-secret: ******************************* # Keycloak client secret for OAuth2
- authorization-grant-type: authorization_code # Authorization grant type for OAuth2
- redirect-uri: 'https://yourtestdomain.ngrok.io/login/oauth2/code/{registrationId}' # Redirect URI for OAuth2
- scope:
- - email # Request email scope for OAuth2
- - profile # Request profile scope for OAuth2
- - openid # Request oidc scope for OAuth2
- client-name: Keycloak # Name of the OAuth2 client
- provider: keycloak
- provider:
- keycloak: # https://www.keycloak.org/securing-apps/oidc-layers
- issuer-uri: http://keycloak.domain.name/realms/myrealmname
- authorization-uri: http://keycloak.domain.name/realms/myrealmname/protocol/openid-connect/auth
- token-uri: http://keycloak.domain.name/realms/myrealmname/protocol/openid-connect/token
- user-info-uri: http://keycloak.domain.name/realms/myrealmname/protocol/openid-connect/userinfo
- user-name-attribute: preferred_username # https://www.keycloak.org/docs-api/latest/rest-api/index.html#UserRepresentation
- jwk-set-uri: http://keycloak.domain.name/realms/myrealmname/protocol/openid-connect/certs
+ # Keycloak is commented out so the local profile starts as-is. Spring Boot resolves a provider
+ # issuer-uri at startup and the placeholder host below does not exist. For OIDC, run the Keycloak
+ # stack instead: docker-compose-keycloak.yml with src/main/resources/application-docker-keycloak.yml.
+ # keycloak:
+ # client-id: ds-spring-user-framework-demo
+ # client-secret: XXXXXX
+ # authorization-grant-type: authorization_code
+ # redirect-uri: 'https://yourtestdomain.ngrok.io/login/oauth2/code/{registrationId}'
+ # scope:
+ # - email
+ # - profile
+ # - openid
+ # client-name: Keycloak
+ # provider: keycloak
+ # provider:
+ # keycloak: # https://www.keycloak.org/securing-apps/oidc-layers
+ # issuer-uri: http://keycloak.domain.name/realms/myrealmname
+ # authorization-uri: http://keycloak.domain.name/realms/myrealmname/protocol/openid-connect/auth
+ # token-uri: http://keycloak.domain.name/realms/myrealmname/protocol/openid-connect/token
+ # user-info-uri: http://keycloak.domain.name/realms/myrealmname/protocol/openid-connect/userinfo
+ # user-name-attribute: preferred_username
+ # jwk-set-uri: http://keycloak.domain.name/realms/myrealmname/protocol/openid-connect/certs
# apple: # This isn't working currently
# client-id: com.digitalsanctuary.springuserapp
# client-secret: XXXXXX
From 0b65c4d937fc55c362bb59341745286d68b6070a Mon Sep 17 00:00:00 2001
From: Devon Hillard
Date: Sat, 15 Aug 2026 02:31:00 -0600
Subject: [PATCH 24/26] fix: restore dropped unprotectedURIs and skip
verification email in the keycloak stack
application-playwright-test.yml replaces user.security.unprotectedURIs
wholesale, so it had silently lost /user/registration/passwordless,
/webauthn/authenticate/**, /login/webauthn and /actuator/health relative to the
base list in application.yml. The override now matches that list entry for
entry, with a comment saying it has to.
docker-compose-keycloak.yml now sets USER_REGISTRATION_SENDVERIFICATIONEMAIL
false the way compose.yaml does, so form registration in that stack no longer
creates a disabled account waiting on mail the bundled relay cannot deliver.
Also drops a stray trailing # after container_name.
---
docker-compose-keycloak.yml | 7 ++++++-
src/main/resources/application-playwright-test.yml | 6 ++++--
2 files changed, 10 insertions(+), 3 deletions(-)
diff --git a/docker-compose-keycloak.yml b/docker-compose-keycloak.yml
index 61d9efe..2ad34e4 100644
--- a/docker-compose-keycloak.yml
+++ b/docker-compose-keycloak.yml
@@ -1,7 +1,7 @@
services:
myapp-db:
image: mariadb:12.2
- container_name: springuser-db #
+ container_name: springuser-db
volumes:
- userdb:/var/lib/mysql
environment:
@@ -82,6 +82,11 @@ services:
SPRING_MAIL_PROPERTIES_MAIL_SMTP_AUTH: "false"
SPRING_MAIL_PROPERTIES_MAIL_SMTP_STARTTLS_ENABLE: "false"
SPRING_MAIL_PROPERTIES_MAIL_SMTP_STARTTLS_REQUIRED: "false"
+ # The mailserver container is a relay with no route to real inboxes, so a verification link
+ # would never arrive. With this false the framework enables new accounts immediately and you
+ # can log in straight after registering. To exercise verification instead, set this to true
+ # and point the SPRING_MAIL_* values above at a real SMTP server.
+ USER_REGISTRATION_SENDVERIFICATIONEMAIL: "false"
healthcheck:
test: ["CMD", "wget", "-q", "--spider", "http://localhost:8080/actuator/health"]
interval: 30s
diff --git a/src/main/resources/application-playwright-test.yml b/src/main/resources/application-playwright-test.yml
index c70ad64..26de276 100644
--- a/src/main/resources/application-playwright-test.yml
+++ b/src/main/resources/application-playwright-test.yml
@@ -33,8 +33,10 @@ user:
# Allow the passkey-only "set initial password" flow to run without a StepUpService (SUF-02); the demo has no
# StepUpService bean, so without this the flow returns HTTP 403.
allowInitialPasswordSetWithoutStepUp: true
- # Test API endpoints are handled by TestApiSecurityConfig with IP whitelist restriction
- unprotectedURIs: /,/index.html,/favicon.ico,/apple-touch-icon-precomposed.png,/css/*,/js/*,/js/user/*,/js/event/*,/js/utils/*,/img/**,/user/registration,/user/resendRegistrationToken,/user/resetPassword,/user/registrationConfirm,/user/changePassword,/user/savePassword,/oauth2/authorization/*,/login,/user/login,/user/login.html,/swagger-ui.html,/swagger-ui/**,/v3/api-docs/**,/event/,/event/list.html,/event/**,/about.html,/error,/error.html
+ # Test API endpoints are handled by TestApiSecurityConfig with IP whitelist restriction.
+ # This replaces the base list wholesale rather than adding to it, so it has to repeat every entry from
+ # application.yml. Keep it in sync with that list when either changes.
+ unprotectedURIs: /,/index.html,/favicon.ico,/apple-touch-icon-precomposed.png,/css/*,/js/*,/js/user/*,/js/event/*,/js/utils/*,/img/**,/user/registration,/user/registration/passwordless,/user/resendRegistrationToken,/user/resetPassword,/user/registrationConfirm,/user/changePassword,/user/savePassword,/oauth2/authorization/*,/login,/user/login,/user/login.html,/swagger-ui.html,/swagger-ui/**,/v3/api-docs/**,/event/,/event/list.html,/event/**,/about.html,/error,/error.html,/webauthn/authenticate/**,/login/webauthn,/actuator/health
logging:
level:
From 9ba5ef01d7e27ab871dea9baa70464cc9d375cde Mon Sep 17 00:00:00 2001
From: Devon Hillard
Date: Sat, 15 Aug 2026 02:31:09 -0600
Subject: [PATCH 25/26] docs: correct testing commands, profile facts, and
stale citations
- README: the Playwright block ran ./gradlew from playwright/, and the install
line was redundant since playwrightTest depends on playwrightBrowsers and
playwrightInstall; passwordless registration lives in register.js, not only
webauthn-*.js; the layout claimed one application-.yml per profile,
which is false for local, test and registration-guard, and omitted
resources/messages/; playwright-test disables the verification and
password-reset emails, not all outbound mail
- CLAUDE.md: the profile list in the bootRun comment was four of eight; the
profile entity is DemoUserProfile
- AUTHENTICATION.md: troubleshooting said Google and Facebook will not call
back to localhost, contradicting the OAuth2 section above it; notes that form
registration in the Keycloak stack also skips verification, and that the
bootRun database container name derives from the checkout directory
- TESTING.md: 64 test files, not 62; the demo test layout was missing
controller/ and user/profile/session/; the disabled-test categories do not
cover AdminUserManagementTest or SecurityConfigurationTest; Gradle tasks run
from the repository root
- CONFIGURATION.md: playwright-test also overrides spring.datasource.* and
restates unprotectedURIs in full
- Line citations into application-local.yml-example shifted by three
---
CLAUDE.md | 4 ++--
README.md | 21 ++++++++++++++-------
docs/AUTHENTICATION.md | 23 +++++++++++++++--------
docs/CONFIGURATION.md | 8 ++++----
docs/DEVELOPMENT.md | 2 +-
docs/TESTING.md | 21 ++++++++++++++-------
6 files changed, 50 insertions(+), 29 deletions(-)
diff --git a/CLAUDE.md b/CLAUDE.md
index a31af19..536fc37 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -9,7 +9,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
# Standard run
./gradlew bootRun
-# Run with specific profile (local, dev, test, docker-keycloak)
+# Run with specific profile (see "Configuration Profiles" below for the full list)
./gradlew bootRun --args='--spring.profiles.active=local'
# Build and run with debugging
@@ -60,7 +60,7 @@ This is a Spring Boot demo application showcasing the [Spring User Framework](ht
### Important Conventions
-1. **No Custom User Entity**: This demo uses the framework's User entity directly. Custom user data goes in separate entities (like UserProfile).
+1. **No Custom User Entity**: This demo uses the framework's User entity directly. Custom user data goes in separate entities (like `DemoUserProfile`).
2. **Configuration Profiles**:
- `local`: Development with local database
diff --git a/README.md b/README.md
index 543975d..3e416a4 100644
--- a/README.md
+++ b/README.md
@@ -33,7 +33,7 @@ and framework 3.5.1 combination on Java 17, `git checkout v1.0.0-springboot3` af
| Cleaning up application data when an account is deleted | [`UserProfileDeletionListener`](src/main/java/com/digitalsanctuary/spring/demo/user/profile/UserProfileDeletionListener.java) | [EXTENDING.md](docs/EXTENDING.md#cleaning-up-application-data-when-a-user-is-deleted) |
| An application domain (events) using privilege-based access control | [`event/`](src/main/java/com/digitalsanctuary/spring/demo/event), roles in [`application.yml`](src/main/resources/application.yml) | [EXTENDING.md](docs/EXTENDING.md#building-your-own-domain-on-the-framework-events) |
| Allowing or denying registrations through the `RegistrationGuard` SPI | [`DomainRegistrationGuard`](src/main/java/com/digitalsanctuary/spring/demo/registration/DomainRegistrationGuard.java), `registration-guard` profile | [AUTHENTICATION.md](docs/AUTHENTICATION.md#registration-guard) |
-| Passkey sign-in, enrollment, management, and passwordless registration | [`static/js/user/`](src/main/resources/static/js/user) (`webauthn-*.js`) | [AUTHENTICATION.md](docs/AUTHENTICATION.md#passkeys) |
+| Passkey sign-in, enrollment, management, and passwordless registration | [`static/js/user/`](src/main/resources/static/js/user) (`register.js`, `webauthn-*.js`) | [AUTHENTICATION.md](docs/AUTHENTICATION.md#passkeys) |
| Two-factor login, password plus passkey | [`application-mfa.yml`](src/main/resources/application-mfa.yml), [`user/mfa/`](src/main/resources/templates/user/mfa) | [AUTHENTICATION.md](docs/AUTHENTICATION.md#mfa) |
| OAuth2 login with Google and Facebook | [`application-local.yml-example`](src/main/resources/application-local.yml-example) | [AUTHENTICATION.md](docs/AUTHENTICATION.md#oauth2-with-google-and-facebook) |
| OIDC login against a bundled Keycloak, as a runnable stack | [`docker-compose-keycloak.yml`](docker-compose-keycloak.yml), [`keycloak/`](keycloak) | [AUTHENTICATION.md](docs/AUTHENTICATION.md#keycloak) |
@@ -52,14 +52,15 @@ src/main/java/com/digitalsanctuary/spring/demo/
├── event/ the example domain: entity, repository, service, page and API controllers
├── registration/ DomainRegistrationGuard, the RegistrationGuard SPI sample
├── service/ CustomUserEmailService, a framework service replaced with @Primary
-├── test/ the test-only API and its security config, playwright-test profile only
+├── test/ api/ the test-only API, config/ its security config; playwright-test only
├── user/profile/ the profile entity, repository, service, session holder, and listeners
├── util/ LocaleConfiguration
└── web/ DemoTemplateModelAdvice
src/main/resources/
├── static/js/ user/ (one module per page), admin/, utils/, shared.js
├── templates/ layout.html plus fragments/, user/, event/, admin/, mail/
-├── application.yml base configuration, with one application-.yml per profile
+├── messages/ messages.properties, the UI and validation message bundle
+├── application.yml base configuration, overridden by application-.yml where needed
└── data-local.sql sample events, loaded under the local profile
src/test/java/com/digitalsanctuary/spring/
├── demo/ tests for this application's own code
@@ -68,6 +69,10 @@ playwright/ E2E specs, fixtures, and playwright.config.ts
keycloak/ realm export and TLS material for the Keycloak stack
```
+Not every profile has its own file: `local` is a gitignored copy of `application-local.yml-example`,
+`test` lives in `src/test/resources/application-test.properties`, and `registration-guard` has no
+file at all. [docs/CONFIGURATION.md](docs/CONFIGURATION.md) lists what each one overrides.
+
## Quick start
### Docker
@@ -122,7 +127,7 @@ and its imported `demo` realm. Ports, credentials, and the login walkthrough are
| `dev` | Debug-heavy dev server; what the `compose.yaml` Docker stack runs |
| `prd` | Production settings: env-driven datasource and URLs, strict cookies, template caching |
| `test` | The JUnit suite on H2, applied automatically by `./gradlew test` |
-| `playwright-test` | Add-on: enables the loopback-only test API and disables outbound mail for E2E runs |
+| `playwright-test` | Add-on: enables the loopback-only test API and turns off the verification and password-reset emails for E2E runs |
| `docker-keycloak` | OIDC against the bundled Keycloak; set for you inside `docker-compose-keycloak.yml` |
| `mfa` | Add-on: requires PASSWORD plus WEBAUTHN, for example `local,mfa` |
| `registration-guard` | Add-on: activates the domain-restricted registration guard, for example `local,registration-guard` |
@@ -164,11 +169,13 @@ The framework:
## Testing
```bash
-./gradlew test # JUnit suite, test profile, H2
-cd playwright && npm ci && npx playwright install # once
-./gradlew playwrightTest # E2E, starts the app itself
+./gradlew test # JUnit suite, test profile, H2
+./gradlew playwrightTest # E2E; starts the app itself
```
+Run both from the repository root. `playwrightTest` depends on `playwrightBrowsers` and
+`playwrightInstall`, so it installs the npm dependencies and the browsers on its first run.
+
Details, including the MFA-only Playwright project and the test-only API, are in
[docs/TESTING.md](docs/TESTING.md).
diff --git a/docs/AUTHENTICATION.md b/docs/AUTHENTICATION.md
index 2c1470f..5509a8d 100644
--- a/docs/AUTHENTICATION.md
+++ b/docs/AUTHENTICATION.md
@@ -25,7 +25,7 @@ failed logins and lockouts land in the audit log.
([`resend-verification.js`](../src/main/resources/static/js/user/resend-verification.js) posts
`POST /user/resendRegistrationToken`).
- `false`: the account is created enabled, the framework logs the user straight in, and the browser
- lands on `/user/registration-complete.html`. `application-local.yml-example:128` sets it false and
+ lands on `/user/registration-complete.html`. `application-local.yml-example:131` sets it false and
the Docker demo stack sets `USER_REGISTRATION_SENDVERIFICATIONEMAIL: "false"` (`compose.yaml:85`),
so neither documented run path needs a working SMTP server.
3. Log in at `/user/login.html` ([`login.js`](../src/main/resources/static/js/user/login.js)). The form
@@ -77,7 +77,7 @@ instead of `/user/updatePassword`
That endpoint is guarded (framework SUF-02): with no current password to verify it requires a
`StepUpService` bean, and returns `403` when none exists unless
`user.security.allowInitialPasswordSetWithoutStepUp` is `true`. This demo has no `StepUpService`, so it
-sets the flag true where the flow has to be demonstrable (`application-local.yml-example:138`,
+sets the flag true where the flow has to be demonstrable (`application-local.yml-example:141`,
`application-mfa.yml:24`, `application-playwright-test.yml:35`) and leaves it at the secure default
`false` in `prd` (`application-prd.yml:50-52`).
@@ -119,7 +119,7 @@ plus its mapping in
`user.registration.googleEnabled` and `user.registration.facebookEnabled` decide whether the buttons render
on `/user/login.html` and `/user/register.html`; both are `false` in `application.yml:114-115` and `true` in
-`application-local.yml-example:129-130`. They link to `/oauth2/authorization/google` and
+`application-local.yml-example:132-133`. They link to `/oauth2/authorization/google` and
`/oauth2/authorization/facebook`, already covered by `/oauth2/authorization/*` in `unprotectedURIs`
(`application.yml:160`). Client IDs and secrets belong in `application-local.yml` (gitignored), never in
`application.yml`; copy the filled-in shape at
@@ -132,7 +132,7 @@ the provider console and set as `redirect-uri`. Facebook enforces HTTPS on redir
apps created since March 2018, so a plain-HTTP localhost callback there depends on the app's settings,
normally while it is in development mode. Reach for `ngrok http 8080` when you need a public HTTPS callback
or want to test from another device; then also set `user.security.appUrl` to the tunnel URL
-(`application-local.yml-example:132-135`), or emailed links still point at localhost.
+(`application-local.yml-example:135-138`), or emailed links still point at localhost.
## Keycloak
@@ -159,7 +159,9 @@ instructions are in [`keycloak/README.md`](../keycloak/README.md). The essential
Open http://localhost:8080/user/login.html, click "Login with Keycloak", sign in as `demo` / `demo`.
First login creates a local account with provider `KEYCLOAK` and email `demo@example.com`. The realm is
`demo`, not `master`, so every OIDC URL is `/realms/demo/...`; `admin` / `admin` is a master realm
-account and cannot sign in to the demo app.
+account and cannot sign in to the demo app. The app's own registration form still works in this stack, and
+like the `compose.yaml` stack it sets `USER_REGISTRATION_SENDVERIFICATIONEMAIL: "false"`, so an account
+registered there is enabled immediately rather than waiting on mail the bundled relay cannot deliver.
The compose file sets `SPRING_PROFILES_ACTIVE: docker-keycloak`, and every value in
[`application-docker-keycloak.yml`](../src/main/resources/application-docker-keycloak.yml) comes from an
environment variable in [`keycloak.env`](../keycloak.env): `DS_SPRING_USER_KEYCLOAK_CLIENT_ID`,
@@ -249,7 +251,9 @@ WHERE u.email = 'you@example.com';
Get a shell on the right database first: `docker exec -it springuserframeworkdemoapp-mariadb-1 mariadb
-uspringuser -pspringuser springuser` for `./gradlew bootRun`, or the same with `springuser-db` for the
-`docker compose up` stack. Log out and back in afterwards, since authorities are loaded at login.
+`docker compose up` stack. Compose derives the `bootRun` container's name from the checkout directory, so
+run `docker ps` to confirm it if you cloned into a differently named directory. Log out and back in
+afterwards, since authorities are loaded at login.
## Troubleshooting
@@ -264,8 +268,11 @@ browser console, not the server log.
**OAuth2 or OIDC login fails or redirects wrongly.** Check the client ID and secret in
`application-local.yml` (for Keycloak, `DS_SPRING_USER_KEYCLOAK_*` in `keycloak.env`) against what the
provider has, and that the provider's registered callback matches your `redirect-uri`, default shape
-`{baseUrl}/login/oauth2/code/{registrationId}`. Google and Facebook will not call back to `localhost`;
-tunnel with `ngrok http 8080`. For Keycloak, remember the realm is `demo`, so URLs are
+`{baseUrl}/login/oauth2/code/{registrationId}`. A localhost callback is not ruled out: Google exempts
+`http://localhost` redirect URIs from its HTTPS requirement, and Facebook's depends on the app's settings,
+normally while it is in development mode (see [OAuth2 with Google and
+Facebook](#oauth2-with-google-and-facebook)). Tunnel with `ngrok http 8080` when you need a public HTTPS
+callback or want to test from another device. For Keycloak, remember the realm is `demo`, so URLs are
`/realms/demo/...` and the app login is `demo` / `demo`, while `admin` / `admin` only opens the admin
console at http://localhost:8180. A realm re-exported from that console masks the client secret as
`**********`, which then no longer matches `keycloak.env` and breaks login: use the `kc.sh export`
diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md
index 0927787..d9094b6 100644
--- a/docs/CONFIGURATION.md
+++ b/docs/CONFIGURATION.md
@@ -29,7 +29,7 @@ If you omit `--args` entirely, `bootRun` still defaults to `local`: `build.gradl
| `dev` | [`application-dev.yml`](../src/main/resources/application-dev.yml) | Debug-heavy dev server; also what the Docker demo stack (`compose.yaml`) runs the app container as | Debug logging, insecure session cookie, `audit.flushOnWrite: true` | `--spring.profiles.active=dev` |
| `prd` | [`application-prd.yml`](../src/main/resources/application-prd.yml) | Production | Thymeleaf caching on, `ddl-auto: validate`, env-driven datasource, strict/secure cookies, `WARN` logging, limited actuator exposure, env-driven WebAuthn RP identity and `appUrl`, `requireCanonicalAppUrl: true`, no fallback for the remember-me key | `--spring.profiles.active=prd` |
| `test` | [`src/test/resources/application-test.properties`](../src/test/resources/application-test.properties) | Automated JUnit suite | Per-context isolated H2 database, MFA off, test-only `unprotectedURIs`. It also sets `maxFailedLoginAttempts`/`lockoutDurationMinutes` (lines 16-17), but those aren't the framework's property names (`failedLoginAttempts`/`accountLockoutDuration`), so they don't bind; lockout stays at the inherited default (10 attempts / 30 min) | Applied automatically by `./gradlew test` |
-| `playwright-test` | [`application-playwright-test.yml`](../src/main/resources/application-playwright-test.yml) | Playwright E2E runs; enables the Test API (`TestDataController`, `TestApiSecurityConfig`, localhost-only) | Disables verification/reset emails, pins `appUrl` to `http://localhost:8080`, `allowInitialPasswordSetWithoutStepUp: true`, MFA off | Combine with a base profile, e.g. `local,playwright-test` (see [TESTING.md](TESTING.md)) |
+| `playwright-test` | [`application-playwright-test.yml`](../src/main/resources/application-playwright-test.yml) | Playwright E2E runs; enables the Test API (`TestDataController`, `TestApiSecurityConfig`, localhost-only) | Disables verification/reset emails, points `spring.datasource.*` at the same local MariaDB the `local` profile uses, pins `appUrl` to `http://localhost:8080`, `allowInitialPasswordSetWithoutStepUp: true`, MFA off, and restates `user.security.unprotectedURIs` in full (a list property is replaced wholesale, not merged, so this copy has to match the base list in `application.yml:160`) | Combine with a base profile, e.g. `local,playwright-test` (see [TESTING.md](TESTING.md)) |
| `docker-keycloak` | [`application-docker-keycloak.yml`](../src/main/resources/application-docker-keycloak.yml) (tracked; holds only `${...}` placeholders, nothing to copy) | OIDC login against the bundled Keycloak stack; see [`keycloak/README.md`](../keycloak/README.md) and [AUTHENTICATION.md#keycloak](AUTHENTICATION.md#keycloak) for the full walkthrough | Adds the Keycloak OAuth2 client/provider from `DS_SPRING_USER_KEYCLOAK_*` env vars (deliberately no `issuer-uri`), insecure session cookie | `--spring.profiles.active=docker-keycloak`, normally set for you as `SPRING_PROFILES_ACTIVE` inside `docker-compose-keycloak.yml` |
| `mfa` | [`application-mfa.yml`](../src/main/resources/application-mfa.yml) | Add-on: require PASSWORD + WEBAUTHN | `user.mfa.enabled: true` (base `application.yml:126` has it `false`); once enabled, the framework auto-unprotects the configured MFA entry-point URIs at runtime, including the challenge page, so a partially-authenticated user can reach them; the profile's yml additionally adds the passkey enrollment endpoints `/webauthn/register/options` and `/webauthn/register` to `unprotectedURIs` (line 25) so that user can register their first passkey; `allowInitialPasswordSetWithoutStepUp: true` | Combine with a base profile, e.g. `local,mfa` |
| `registration-guard` | none (no yml; `@Profile("registration-guard")` on [`DomainRegistrationGuard`](../src/main/java/com/digitalsanctuary/spring/demo/registration/DomainRegistrationGuard.java)) | Add-on: domain-restricted registration demo | Activates a `RegistrationGuard` bean that restricts form/passwordless registration to one email domain (`registration.guard.allowed-domain`, default `@example.com`); OAuth2/OIDC registration is unaffected | Combine with a base profile, e.g. `local,registration-guard` |
@@ -42,7 +42,7 @@ WebAuthn passkeys ([#passkeys](AUTHENTICATION.md#passkeys)), and `registration-g
## Getting started locally
1. Copy the example file and edit it: `cp src/main/resources/application-local.yml-example src/main/resources/application-local.yml`. It is gitignored, so your edits (and any real credentials) never get committed.
-2. This step matters: base `application.yml` leaves `user.registration.sendVerificationEmail: true` (`application.yml:113`) and points `spring.mail.host` at an SES endpoint with no credentials (`application.yml:2-6`), so a fresh clone that skips step 1 starts fine but can never send the verification email a new registration needs, and you cannot log in. `application-local.yml-example` sets `sendVerificationEmail: false` (`application-local.yml-example:128`), so once you copy it, registered accounts are enabled immediately. To exercise the real verification flow instead, set it back to `true` and point `spring.mail.*` at a real SMTP server. The Docker demo stack (`compose.yaml`) disables verification email the same way; see [Mail](#mail).
+2. This step matters: base `application.yml` leaves `user.registration.sendVerificationEmail: true` (`application.yml:113`) and points `spring.mail.host` at an SES endpoint with no credentials (`application.yml:2-6`), so a fresh clone that skips step 1 starts fine but can never send the verification email a new registration needs, and you cannot log in. `application-local.yml-example` sets `sendVerificationEmail: false` (`application-local.yml-example:131`), so once you copy it, registered accounts are enabled immediately. To exercise the real verification flow instead, set it back to `true` and point `spring.mail.*` at a real SMTP server. The Docker demo stack (`compose.yaml`) disables verification email the same way; see [Mail](#mail).
3. At minimum, set `spring.mail.username`, `spring.mail.password`, and `spring.mail.host` if you want outbound mail to work locally. Set the `spring.security.oauth2.client.registration.*` client IDs/secrets only if you want to exercise social/Keycloak login.
4. Docker Compose integration: base `application.yml` sets `spring.docker.compose.file: compose.dev.yaml` (lines 66-73), so `./gradlew bootRun` under any profile starts a MariaDB 12.2 container (`springuser`/`springuser`, port 3306) automatically and stops it when the app stops. Set `spring.docker.compose.enabled: false` (commented hint right below the `file:` line) to point at a database you manage yourself instead.
5. Seed data: `application-local.yml-example` sets `spring.sql.init.mode: always` and `spring.sql.init.platform: local`, plus `spring.jpa.defer-datasource-initialization: true` (lines 25-30), so every boot under the `local` profile loads [`data-local.sql`](../src/main/resources/data-local.sql) (sample events). The script uses `INSERT IGNORE`, so re-running it on every start is safe.
@@ -77,7 +77,7 @@ pattern works for any other key, e.g. `USER_SECURITY_BCRYPTSTRENGTH` for `user.s
- `spring.mail.username`, `spring.mail.password`, `spring.mail.host`, `spring.mail.port` (`application.yml:2-6`) configure the SMTP transport used for verification, password-reset, and notification email. The base file's `host` is a placeholder SES endpoint; set real credentials in your profile.
- `user.registration.sendVerificationEmail` (`application.yml:113`) controls whether a new account must click a verification link before it can log in. `false` enables the account immediately at registration.
- The Docker demo stack's `mailserver` service (`compose.yaml`) is a relay only: `SMTP_ONLY: 1` (`compose.yaml:48`) with no route to real inboxes. That stack sets `USER_REGISTRATION_SENDVERIFICATIONEMAIL: "false"` (`compose.yaml:85`) so registered accounts activate immediately instead of waiting on mail nothing will deliver.
-- `user.mail.fromAddress` sets the `From` address on outbound mail; it is set per profile (e.g. `application-local.yml-example:141`), not in the base file.
+- `user.mail.fromAddress` sets the `From` address on outbound mail; it is set per profile (e.g. `application-local.yml-example:144`), not in the base file.
## Security settings this demo sets
@@ -87,7 +87,7 @@ pattern works for any other key, e.g. `USER_SECURITY_BCRYPTSTRENGTH` for `user.s
- **Default action / protected surface**: `defaultAction: deny` with an explicit `unprotectedURIs` allowlist, plus `protectedURIs` and `disableCSRFdURIs` (`application.yml:150-162`).
- **Remember-me**: `rememberMe.enabled: true`, signing key from `REMEMBER_ME_KEY` with a random-UUID fallback (`application.yml:151-158`).
- **Canonical app URL** (`user.security.appUrl`): prevents Host-header poisoning of password-reset/verification email links (SUF-01 / CWE-640); when unset, the framework derives the host from the (spoofable) request `Host` header and logs a startup warning. Base `application.yml:145` sets `http://localhost:8080`; `prd` drives it from `${APP_URL:https://example.com}` (`application-prd.yml:47`) and also sets `requireCanonicalAppUrl: true` (`application-prd.yml:49`), so `prd` fails to start without it; `playwright-test` pins it explicitly to `http://localhost:8080` (`application-playwright-test.yml:32`).
-- **Allow initial password set without step-up** (`user.security.allowInitialPasswordSetWithoutStepUp`): controls `POST /user/setPassword`, which lets a passkey-only account set an initial password. As of the framework's SUF-02 hardening, this endpoint returns `403` unless a `StepUpService` bean exists or this is `true`. This demo has no `StepUpService`, so it sets the flag `true` in `local` (`application-local.yml-example:138`), `mfa` (`application-mfa.yml:24`), and `playwright-test` (`application-playwright-test.yml:35`) to keep the passkey flow usable, and leaves it at its secure default `false` in `prd` (`application-prd.yml:50`).
+- **Allow initial password set without step-up** (`user.security.allowInitialPasswordSetWithoutStepUp`): controls `POST /user/setPassword`, which lets a passkey-only account set an initial password. As of the framework's SUF-02 hardening, this endpoint returns `403` unless a `StepUpService` bean exists or this is `true`. This demo has no `StepUpService`, so it sets the flag `true` in `local` (`application-local.yml-example:141`), `mfa` (`application-mfa.yml:24`), and `playwright-test` (`application-playwright-test.yml:35`) to keep the passkey flow usable, and leaves it at its secure default `false` in `prd` (`application-prd.yml:50`).
For OAuth2/OIDC, WebAuthn passkeys, MFA, and the registration guard, see
[AUTHENTICATION.md](AUTHENTICATION.md).
diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md
index c6f44b2..203bff6 100644
--- a/docs/DEVELOPMENT.md
+++ b/docs/DEVELOPMENT.md
@@ -49,7 +49,7 @@ to `https://`, not conditional on scheme. The port comes from the framework's
server always listens on plain HTTP at its default port, 35729, regardless of that flag, so:
- `spring.devtools.livereload.https=true`, already set by
- `application-local.yml-example:107` (so copying that example file puts you on this path
+ `application-local.yml-example:110` (so copying that example file puts you on this path
immediately; the `docker-keycloak` profile does not set this property): the script requests
`https://localhost:35739/livereload.js`. Nothing listens there by default; run
`mitmproxy --mode reverse:http://localhost:35729 -p 35739` to terminate TLS on 35739 and forward to
diff --git a/docs/TESTING.md b/docs/TESTING.md
index 34fc9d5..70d4d70 100644
--- a/docs/TESTING.md
+++ b/docs/TESTING.md
@@ -33,7 +33,7 @@ tests written against `OAuth2TestConfiguration` but not currently used by an act
management surface (`api/`, `concurrent/`, `config/`, `integration/`, `json/`, `oauth2/`,
`security/`).
- `src/test/java/com/digitalsanctuary/spring/demo/...`: tests for the demo app's own code
- (`event/`, `mfa/`, `registration/`, `DemoTests.java`).
+ (`controller/`, `event/`, `mfa/`, `registration/`, `user/profile/session/`, `DemoTests.java`).
[`IntegrationTest`](../src/test/java/com/digitalsanctuary/spring/user/test/annotations/IntegrationTest.java)
composes `@SpringBootTest` (against `UserDemoApplication`), `@AutoConfigureMockMvc`,
@@ -50,7 +50,7 @@ Test data builders live in
/usr/bin/find src/test -name '*.java' | xargs grep -l @Disabled | wc -l # files with @Disabled
```
-As of this writing that's 62 test files, 17 with `@Disabled`. 15 were disabled during a REST API
+As of this writing that's 64 test files, 17 with `@Disabled`. 15 were disabled during a REST API
alignment pass and point back to this file; they fall into these categories:
- **Auth expectations**: test expects a specific JSON error body on auth failure; Spring
@@ -65,6 +65,10 @@ alignment pass and point back to this file; they fall into these categories:
- **Transaction isolation**: a user created in test setup isn't visible to the REST endpoint
within the same transaction.
+The categories are representative, not exhaustive: `AdminUserManagementTest` (role hierarchy and
+admin operations configuration) and one case in `SecurityConfigurationTest` (`/protected.html`
+returns 404) fit none of them.
+
They're kept, not deleted: each documents an expected behavior or a gap worth revisiting as a
framework improvement. The other two (`DisabledTestExample.java`,
`AccountLockoutIntegrationTest.java`) are disabled for unrelated, self-contained reasons
@@ -72,16 +76,19 @@ documented inline.
## Playwright E2E tests
-Tests live in [`playwright/`](../playwright) (`@playwright/test`). Install once:
+Tests live in [`playwright/`](../playwright) (`@playwright/test`). To drive them through npm, install
+once:
```bash
cd playwright && npm ci && npx playwright install
```
-Run via npm (`playwright/package.json`: `test`, `test:chromium`, `test:headed`, `test:ui`) or the
-Gradle wrapper tasks in [`build.gradle`](../build.gradle) (`verification` group):
-`./gradlew playwrightTest` / `playwrightTestChromium` (both depend on `playwrightBrowsers` /
-`playwrightInstall`).
+Then run the npm scripts from `playwright/` (`playwright/package.json`: `test`, `test:chromium`,
+`test:headed`, `test:ui`). The Gradle wrapper tasks in [`build.gradle`](../build.gradle)
+(`verification` group) are the other route, and they run from the repository root:
+`./gradlew playwrightTest` / `playwrightTestChromium`. Both depend on `playwrightBrowsers` and
+`playwrightInstall`, so the Gradle route installs the npm dependencies and the browsers itself and
+needs no separate install step.
[`playwright.config.ts`](../playwright/playwright.config.ts) starts the app itself via
`webServer`: `./gradlew bootRun --args="--spring.profiles.active=${APP_PROFILES:-local,playwright-test}"`
From 744422749d83a1f62538b358705023305117217c Mon Sep 17 00:00:00 2001
From: Devon Hillard
Date: Sat, 15 Aug 2026 02:31:13 -0600
Subject: [PATCH 26/26] docs: log the local quick start, unprotectedURIs, and
keycloak mail fixes
---
CHANGELOG.md | 11 +++++++++++
1 file changed, 11 insertions(+)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index cf2f6e3..3e8b500 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -46,6 +46,17 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
- `DemoSessionProfile` is now `@SessionScopedProfile`; as a plain `@Component` it was a singleton
shared by every HTTP session
- `DomainRegistrationGuard`'s Javadoc link to the framework's registration guard documentation
+- The `local` quick start, which died at startup once `application-local.yml-example` was copied into
+ place: the example's masked Keycloak `client-secret` was not valid YAML, and its Keycloak
+ `issuer-uri` pointed at a host that does not resolve but that Spring Boot fetches at startup. The
+ Keycloak client registration and provider blocks are now commented out, with a pointer to the
+ Keycloak stack for OIDC
+- `application-playwright-test.yml` replaced `user.security.unprotectedURIs` wholesale and lost
+ `/user/registration/passwordless`, `/webauthn/authenticate/**`, `/login/webauthn`, and
+ `/actuator/health`; the override now matches the base list
+- `docker-compose-keycloak.yml` sets `USER_REGISTRATION_SENDVERIFICATIONEMAIL: "false"` like
+ `compose.yaml`, so form registration in that stack no longer creates an account waiting on mail the
+ bundled relay cannot deliver
### Removed
- Root `CONFIG.md` (a stale copy of the framework's property reference), `docs/HELP.md` (Spring