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 [![License](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](https://opensource.org/licenses/Apache-2.0) -[![Java Version](https://img.shields.io/badge/Java-21%2B-brightgreen)](https://www.oracle.com/java/technologies/downloads/) -[![Spring Boot](https://img.shields.io/badge/Spring%20Boot-4.0-green)](https://spring.io/projects/spring-boot) -[![Gradle](https://img.shields.io/badge/Gradle-8.0%2B-blue)](https://gradle.org/) -[![Docker](https://img.shields.io/badge/Docker-Supported-blue)](https://www.docker.com/) -[![PRs Welcome](https://img.shields.io/badge/PRs-welcome-brightgreen.svg)](contributing) -[![Documentation](https://img.shields.io/badge/docs-comprehensive-green)](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. - -![Spring User Framework Demo Screenshot](/docs/images/Register.jpeg) - -## 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: +[![Java](https://img.shields.io/badge/Java-21-brightgreen)](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. + +![Registration page](docs/images/Register.jpeg) + +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 `