diff --git a/.dockerignore b/.dockerignore
new file mode 100644
index 0000000..dec9669
--- /dev/null
+++ b/.dockerignore
@@ -0,0 +1,19 @@
+# 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
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/CHANGELOG.md b/CHANGELOG.md
index e36891f..3e8b500 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -9,6 +9,66 @@ 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
+- 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
+ 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
diff --git a/CLAUDE.md b/CLAUDE.md
index b9d0ea9..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,25 +60,32 @@ 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
- - `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/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/README.md b/README.md
index 308753e..3e416a4 100644
--- a/README.md
+++ b/README.md
@@ -1,828 +1,198 @@
# 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) (`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) |
+| 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/ 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/
+├── 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
+└── 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
+```
+
+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
+
+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
-
----
-
-## Testing
-
-This project includes comprehensive testing with multiple approaches:
-
-### Running Tests
-
-```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.
-
----
+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.
-## 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
-```
+Stop it with Ctrl-C, then `docker compose down -v` to remove the containers and their data.
-**VS Code:**
-```bash
-# Install extensions:
-# - Extension Pack for Java
-# - Spring Boot Extension Pack
-# - Gradle for Java
-```
+### Locally with Gradle
-### Common Development Tasks
+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
-# 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
-```
+`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 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` |
+
+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.
-#### 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
-```
-
-#### 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
-```
+## Testing
-#### 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
+```bash
+./gradlew test # JUnit suite, test profile, H2
+./gradlew playwrightTest # E2E; starts the app itself
```
-### Getting Help
+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.
-- **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)
-
----
+Details, including the MFA-only Playwright project and the test-only API, are in
+[docs/TESTING.md](docs/TESTING.md).
## 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
-
-```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
-```
-
----
-
-## Changelog
-
-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.
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
}
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..2ad34e4 100644
--- a/docker-compose-keycloak.yml
+++ b/docker-compose-keycloak.yml
@@ -1,9 +1,7 @@
-version: "3.8"
-
services:
myapp-db:
image: mariadb:12.2
- container_name: springuser-db #
+ container_name: springuser-db
volumes:
- userdb:/var/lib/mysql
environment:
@@ -67,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
@@ -80,8 +82,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", "curl", "-f", "http://localhost:8080/actuator/health"]
+ test: ["CMD", "wget", "-q", "--spider", "http://localhost:8080/actuator/health"]
interval: 30s
timeout: 10s
retries: 5
@@ -108,10 +115,19 @@ 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
+ # 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/docs/AUTHENTICATION.md b/docs/AUTHENTICATION.md
new file mode 100644
index 0000000..5509a8d
--- /dev/null
+++ b/docs/AUTHENTICATION.md
@@ -0,0 +1,281 @@
+# 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: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
+ 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 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
+ `POST /user/updatePassword`.
+
+## Passkeys
+
+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`, shown in the browser's passkey prompt; and
+`allowedOrigins: http://localhost:8080`, which must equal the browser origin exactly, port included.
+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, 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:
+[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: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: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`).
+
+## MFA
+
+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`. 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.
+
+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 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).
+
+## 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: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
+[`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)
+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:135-138`), or emailed links still point at localhost.
+
+## Keycloak
+
+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 |
+| --- | --- | --- |
+| 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 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`,
+`..._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`, 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, 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. 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
+
+**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}`. 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`
+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.
diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md
new file mode 100644
index 0000000..d9094b6
--- /dev/null
+++ b/docs/CONFIGURATION.md
@@ -0,0 +1,102 @@
+# 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
+
+```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`, `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, 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, 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` |
+
+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: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.
+
+## 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_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
+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:144`), 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`).
+- **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: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).
+
+## 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..203bff6
--- /dev/null
+++ b/docs/DEVELOPMENT.md
@@ -0,0 +1,116 @@
+# 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,
+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
+
+The LiveReload `-->
+```
+
+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: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
+ 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)
+
+## 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 --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
+ 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
+
+- `./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.
diff --git a/docs/EXTENDING.md b/docs/EXTENDING.md
new file mode 100644
index 0000000..7e3f1b0
--- /dev/null
+++ b/docs/EXTENDING.md
@@ -0,0 +1,213 @@
+# 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 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(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
+
+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:111](../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`. `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
+ `/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: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.
+
+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` 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.
+
+## 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`.
+ 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: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
+ 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, 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.
+- [messages/messages.properties](../src/main/resources/messages/messages.properties), wired by
+ `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.
+
+## 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: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: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: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
+[CONFIG.md](https://github.com/devondragon/SpringUserFramework/blob/main/CONFIG.md).
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)
-
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..70d4d70
--- /dev/null
+++ b/docs/TESTING.md
@@ -0,0 +1,124 @@
+# 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
+ (`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`,
+`@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 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
+ 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.
+
+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
+documented inline.
+
+## Playwright E2E tests
+
+Tests live in [`playwright/`](../playwright) (`@playwright/test`). To drive them through npm, install
+once:
+
+```bash
+cd playwright && npm ci && npx playwright install
+```
+
+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}"`
+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/keycloak.env b/keycloak.env
index a585f0a..c17bf11 100644
--- a/keycloak.env
+++ b/keycloak.env
@@ -1,17 +1,37 @@
-# 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
+# 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
KC_METRICS_ENABLED=true
KC_HTTPS_CERTIFICATE_FILE=/opt/keycloak/ssl/certificate.pem
diff --git a/keycloak/README.md b/keycloak/README.md
new file mode 100644
index 0000000..5e5c115
--- /dev/null
+++ b/keycloak/README.md
@@ -0,0 +1,59 @@
+# 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 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 --wait
+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 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: 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, the running server 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 `**********`, 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` says why there is no `issuer-uri`.
diff --git a/keycloak/realm/realm-export.json b/keycloak/realm/realm-export.json
index d93cd28..a817679 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": [
- "*"
+ "http://localhost:8080/login/oauth2/code/keycloak"
],
"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/mise.toml b/mise.toml
index 18090eb..8931355 100644
--- a/mise.toml
+++ b/mise.toml
@@ -1,2 +1,2 @@
[tools]
-java = "17"
+java = "21"
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/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/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());
- }
-
-}
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
deleted file mode 100644
index f69a70d..0000000
--- a/src/main/resources/application-docker-keycloak.yml-example
+++ /dev/null
@@ -1,96 +0,0 @@
-debug: true # Enable or disable debug mode
-
-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
-
-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
- 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}
- 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
-
-server:
- port: 8080
- servlet:
- session:
- cookie:
- secure: false # disabling secure cookie for local development
-
-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
diff --git a/src/main/resources/application-local.yml-example b/src/main/resources/application-local.yml-example
index 171d65b..4153258 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
@@ -48,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
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:
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.
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();
+ }
+}
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();
+ }
+}
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