This demo depends on com.digitalsanctuary:ds-spring-user-framework:5.3.1 (build.gradle). Every
section below names one extension point the framework offers, the demo code that uses it, the configuration that wires
it, and what you would write in your own application to do the same.
Framework reference documentation lives in the library repository: README.md, CONFIG.md, docs/PROFILE.md, docs/REGISTRATION-GUARD.md.
The framework owns the User entity and authentication. Application-specific user data goes in a profile entity that
shares the user's primary key. The demo implements all five steps of
docs/PROFILE.md:
| PROFILE.md step | Framework type | Demo class |
|---|---|---|
| 1. Profile entity | BaseUserProfile |
DemoUserProfile |
| 2. Repository | JpaRepository |
DemoUserProfileRepository |
| 3. Profile service | UserProfileService<T> |
DemoUserProfileService |
| 4. Session holder | BaseSessionProfile<T> |
DemoSessionProfile |
| 5. Auth listener | BaseAuthenticationListener<T> |
DemoAuthenticationListener |
DemoUserProfile is mapped to table demo_user_profile and adds favoriteColor, receiveNewsletter, and a
@OneToMany list of EventRegistration
(table event_registrations, with EventRegistrationRepository).
BaseUserProfile supplies the @Id, the @OneToOne @MapsId link to User, lastAccessed, and locale, so the
profile row's id is the user's id.
DemoUserProfileService implements the two interface methods (getOrCreateProfile, updateProfile) and adds
domain methods registerForEvent(Long profileId, Long eventId) and unregisterFromEvent(Long profileId, Long eventId)
that load managed entities inside the transaction. DemoSessionProfile adds read helpers over the session-held
profile (isRegisteredForEvent, getFavoriteColor) plus refreshProfile(), which re-reads the profile from the
repository after a write so the session is not stale. DemoAuthenticationListener is a constructor-only subclass; the
framework base class loads the profile into the session on successful authentication.
In your app: create the five types with your own field set, keep the profile entity's extra columns out of the
framework's user_account table, and let the base authentication listener populate the session. Note that Spring does
not inherit @Scope into subclasses: annotate your BaseSessionProfile subclass with @SessionScopedProfile (or
repeat the explicit @Scope(value = WebApplicationContext.SCOPE_SESSION, proxyMode = ScopedProxyMode.TARGET_CLASS)),
otherwise it registers as a singleton shared by every HTTP session.
The framework publishes com.digitalsanctuary.spring.user.event.UserPreDeleteEvent inside the deletion transaction,
carrying userId and userEmail (not a live entity).
UserProfileDeletionListener
handles it with @EventListener plus @Transactional, looks the profile up by id (same id as the user), and deletes
it; EventRegistration rows go with it through cascade = ALL, orphanRemoval = true on the profile's collection.
In your app: register one such listener per aggregate that holds a foreign key to the user, and do the work in the
event's transaction so a failed cleanup rolls the deletion back. Whether the account is deleted or only disabled is
controlled by user.actuallyDeleteAccount (application.yml:111).
The Event feature is the "your application" half of the demo. It is ordinary Spring MVC plus JPA that leans on the framework only for identity and authorization:
- Event (table
events) and EventRepository / EventService. - EventAPIController: REST under
/api/events.POST /api/events,PUT /api/events/{id},DELETE /api/events/{id},POST /api/events/{eventId}/registerandPOST /api/events/{eventId}/unregistereach carry a@PreAuthorize.GET /api/eventsandGET /api/events/{id}carry no method-level authorization, but URL-level security still requires an authenticated user because/api/eventsis not listed inunprotectedURIs. The two layers are independent: method annotations refine what URL rules already allow through. - EventPageController: the
Thymeleaf pages
/event/list.html,/event/{eventId}/details.html,/event/create.html,/event/my-events.html. - AdminController gates
/admin/actions.htmlwith@PreAuthorize("hasAuthority('ADMIN_PRIVILEGE')"), the same mechanism applied to a page rather than an API.
The authorities in those annotations are not hard-coded in Java; they come from the framework's role configuration in
application.yml:200-222. user.roles.roles-and-privileges grants
CREATE_EVENT_PRIVILEGE, DELETE_EVENT_PRIVILEGE, and UPDATE_EVENT_PRIVILEGE to ROLE_ADMIN (lines 208-210) and
REGISTER_FOR_EVENT_PRIVILEGE to ROLE_USER (line 219). user.roles.role-hierarchy (lines 220-222) declares
ROLE_ADMIN > ROLE_MANAGER > ROLE_USER, so an admin also holds the user privileges without being granted them twice.
The framework creates the roles and privileges from this configuration at startup.
In your app: define one privilege per action, list it under the roles that should have it, and use
hasAuthority('YOUR_PRIVILEGE') in @PreAuthorize rather than checking role names. Adding a privilege is then a
configuration change, not a code change. Property reference:
CONFIG.md and CONFIGURATION.md.
CustomUserEmailService extends
the framework's UserEmailService and is annotated @Service @Primary, so it replaces the framework bean everywhere it
is injected. It overrides one method, sendForgotPasswordVerificationEmail: when
app.mail.sendPasswordResetEmail is false it creates and persists the reset token but sends no mail, otherwise it
delegates to super. The Playwright profile sets that flag to false
(application-playwright-test.yml:6-8) so E2E tests can read
the token back through the test API instead of an inbox.
In your app: subclass the framework service, add @Primary, keep the constructor signature (the parent takes its
collaborators by constructor), override only the methods you need, and call super on the rest. The same pattern
applies to any framework @Service you want to intercept, for example to route mail through a transactional email
provider.
- DemoTemplateModelAdvice: a
@ControllerAdviceexposingdevOrLocalProfileas a model attribute. Templates cannot call${@environment.acceptsProfiles(...)}in the restricted (layout-decorated) Thymeleaf expression context, so the boolean is precomputed. This is the place to add any demo-only model attribute that does not come from the framework's own${userSecurity}advice. - LocaleConfiguration: a
CookieLocaleResolverdefaulting toLocale.USplus aLocaleChangeInterceptorbound to thelangrequest parameter, so?lang=frsets the session locale in a cookie. The demo ships one bundle only, so nothing visible changes today; the wiring is there for when localized bundles are added.
In your app: use a @ControllerAdvice for cross-cutting view data, and add a locale resolver only if you ship more
than one message bundle.
DomainRegistrationGuard
implements the framework's RegistrationGuard SPI: evaluate(RegistrationContext) returns RegistrationDecision.allow()
for RegistrationSource.OAUTH2 and OIDC, and for form or passwordless registration allows only email addresses ending
in registration.guard.allowed-domain (default @example.com), denying everything else with a message. The bean is
annotated @Profile("registration-guard"), so it is inert until that profile is active
(--spring.profiles.active=local,registration-guard). See AUTHENTICATION.md#registration-guard
for how to run it, and
docs/REGISTRATION-GUARD.md
for the full SPI contract. In your app, one @Component implementing the interface is the whole integration: allowlists,
invite codes, and per-source rules all fit in evaluate.
The framework ships the mail templates but no user-facing HTML; its README points adopters at this repository for the reference set. What to copy:
-
templates/user/ :
login.html,register.html,forgot-password.html,forgot-password-change.html,forgot-password-pending-verification.html,update-user.html,update-password.html,delete-account.html,registration-complete.html,registration-pending-verification.html,request-new-verification-email.html, andmfa/webauthn-challenge.html. The forms post to the fixed/user/*API paths (login.htmlis the exception: its action comes from${userSecurity.loginActionUri}, since the login processing URL is configurable). The framework-provided${userSecurity}model attribute supplies the configurable page URIs used in navigation, for examplefragments/header.htmlandindex.htmllink to${userSecurity.loginPageUri}and${userSecurity.registrationUri}. Page templates need a controller mapping: the framework serves its own known pages, but the demo maps/user/mfa/webauthn-challenge.htmlitself in PageController, because that path is theuser.mfa.webauthnEntryPointUrivalue at application.yml:133. Copyingtemplates/user/mfa/means copying that mapping too. -
templates/layout.html and templates/fragments/ (
header.html,footer.html): the layout dialect shell, the CSRF meta tags every fetch call reads, andsec:authorizedriven navigation. -
templates/mail/:
registration-token.htmlandforgot-password-token.htmlare byte-identical copies of the framework's defaults, placed at the same classpath paths so they take precedence. Edit them in place to restyle the emails. -
static/js/user/, one module per page, calling these endpoints:
Module Endpoints register.jsPOST /user/registration,POST /user/registration/passwordlesslogin.jsthe login form action, plus passkey sign-in via webauthn-authenticate.jsforgot-password.jsPOST /user/resetPasswordreset-password.jsPOST /user/savePasswordresend-verification.jsPOST /user/resendRegistrationTokenupdate-user.jsPOST /user/updateUserupdate-password.jsPOST /user/updatePassword,POST /user/setPassworddelete-account.jsDELETE /user/deleteAccountauth-methods.jsGET /user/auth-methodswebauthn-manage.jsGET /user/webauthn/credentials,PUT /user/webauthn/credentials/{id}/label,DELETE /user/webauthn/credentials/{id},DELETE /user/webauthn/password,GET /user/mfa/statuswebauthn-register.js,webauthn-authenticate.jsthe Spring Security WebAuthn endpoints /webauthn/register/options,/webauthn/register,/webauthn/authenticate/options,/login/webauthnmfa-webauthn-challenge.js,webauthn-utils.jsnone of their own; they delegate to the modules above -
static/js/shared.js (message and error rendering) and static/js/utils/password-validation.js (strength meter) are imported by the page modules, so copy them too.
-
messages/messages.properties, wired by
spring.messages.basename: messages/messages(application.yml:79-80). The framework appends its own bundle after yours, so redefining a framework key here (the file overridesauth.message.*,email.*, and the password-policy messages) replaces the library text.
TestDataController exposes
/api/test/** (user lookup, create, delete, enable, unlock, verification and password-reset token retrieval, health)
for Playwright, and is annotated @Profile("playwright-test") so the bean does not exist otherwise. Its delete
endpoint publishes UserPreDeleteEvent itself so framework listeners clean up first.
TestApiSecurityConfig adds
an @Order(1) SecurityFilterChain matching /api/test/** that disables CSRF and permits the request only when the
remote address is loopback, denying everything else. Both are activated by the playwright-test profile; see
TESTING.md.
In your app: pair the @Profile on the controller with a dedicated, narrow filter chain, and keep the profile out of
production configuration.
These need no code in the demo at all:
- MFA:
user.mfa(application.yml:122-133) declares the factorsPASSWORDandWEBAUTHN(lines 127-129) and the entry-point URIs, but is disabled at line 126. Themfaprofile (application-mfa.yml) only flipsenabled: true, allows the initial password-set flow without aStepUpService, and adds the passkey registration endpoints to the unprotected list so a new user can enroll. - URL protection:
user.security.defaultAction: denyplususer.security.unprotectedURIs(application.yml:150,160) decide what is public; the demo adds its own/event/**and static paths there. - Remember-me:
user.security.rememberMe(application.yml:151-159) enables the cookie, with the signing key read fromREMEMBER_ME_KEYand a random per-start fallback.
Every property above is documented in CONFIGURATION.md and in the framework's CONFIG.md.