Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
55 commits
Select commit Hold shift + click to select a range
888822c
ci: add Gradle build/test workflow (JDK 17 & 21) and CodeQL
devondragon Jun 12, 2026
2ce825f
test: fix LogoutSuccessServiceTest strict-stubbing failures from Spri…
devondragon Jun 12, 2026
9a9d35a
fix(security): wire SessionRegistry into filter chain so session inva…
devondragon Jun 12, 2026
d8acd43
fix(security): invalidate sessions on password change/reset
devondragon Jun 12, 2026
92ad1cc
fix(security): enforce enabled/locked account status on OAuth2/OIDC/W…
devondragon Jun 12, 2026
f031fe8
fix(security): enable Spring Security factor merging so MFA login wor…
devondragon Jun 12, 2026
47cfda9
fix(arch): make JPA auditing conditional so it doesn't hijack consume…
devondragon Jun 12, 2026
a02faad
fix(arch): make library SecurityFilterChain ordered + conditional, fi…
devondragon Jun 12, 2026
6a77617
feat(extensibility): allow consumers to override core beans via @Cond…
devondragon Jun 13, 2026
bf7527c
feat(extensibility): allow overriding AuditLogWriter and MailService …
devondragon Jun 13, 2026
334cecc
fix(security): hash tokens at rest with dual-read, single active toke…
devondragon Jun 13, 2026
65be92b
fix(security): exclude password from User.toString; redact tokens/pri…
devondragon Jun 13, 2026
4e02429
fix(security): validate email_verified where available; sanitize OAut…
devondragon Jun 13, 2026
e64abd7
fix(security): rotate session id on programmatic login; wire LogoutSu…
devondragon Jun 13, 2026
c1a13dc
fix(concurrency): atomic failed-login increment to prevent lockout-ev…
devondragon Jun 13, 2026
93fea9f
fix(concurrency): SERIALIZABLE on primary registration, remove no-op …
devondragon Jun 13, 2026
b194c7a
perf: hash passwords outside the DB transaction to avoid holding conn…
devondragon Jun 13, 2026
21f8d99
perf: give mail its own bounded executor so SMTP stalls don't starve …
devondragon Jun 13, 2026
32646d8
perf/ops: audit log rotation, bounded queries, durability docs
devondragon Jun 13, 2026
d8d7115
fix(session): make User Serializable for clustered/persistent sessions
devondragon Jun 13, 2026
653ac44
fix(extensibility): enforce RegistrationGuard in the service for all …
devondragon Jun 13, 2026
e16c148
fix(extensibility): correct BaseSessionProfile scoping (H7); publish …
devondragon Jun 13, 2026
e9ca0ca
build: drop unused Guava + dead test deps; add ArchUnit rules
devondragon Jun 13, 2026
3e68008
fix(config): remove test-only CSRF exemption and test@test.com from s…
devondragon Jun 13, 2026
2880f45
test: re-enable UserApiTest, cover savePassword reset flow and expire…
devondragon Jun 13, 2026
4b5c06c
test: lockout enforced through the real authentication path + config-…
devondragon Jun 13, 2026
419ebee
fix(test-infra): exclude @TestConfiguration/auto-config from library …
devondragon Jun 13, 2026
872274f
test: WebAuthn credential ownership IDOR negative test at the reposit…
devondragon Jun 13, 2026
9a9520a
test: cover WebSecurityConfig authorization branches and CSRF behavior
devondragon Jun 13, 2026
8214eea
test: de-scope JpaAuditingConfigTest stand-in to @TestConfiguration t…
devondragon Jun 13, 2026
e727e8c
test: concurrent duplicate-registration is serialized on Postgres and…
devondragon Jun 13, 2026
d589c91
build: drop now-unused awaitility test dep (Task 9.5 used CountDownLa…
devondragon Jun 13, 2026
daafb8b
fix(security): make internal persist* methods package-private to clos…
devondragon Jun 13, 2026
ce7db08
fix(security): atomically consume verification token on the valid path
devondragon Jun 13, 2026
a5d8a92
fix(security): reject Facebook OAuth2 login when email is explicitly …
devondragon Jun 13, 2026
5eecb0a
fix(security): move unconditional security beans to auto-config; relo…
devondragon Jun 13, 2026
1a9da5e
refactor(security): isolate MFA filter-merging post-processor; docume…
devondragon Jun 13, 2026
10b7cda
fix(security): avoid logging PII in OAuth2 failure handler; expose at…
devondragon Jun 13, 2026
9845cbc
test: convert LoginAttemptServiceTest to AssertJ per project convention
devondragon Jun 13, 2026
b49b3f5
test: cover expired-token cleanup path of validateAndConsumePasswordR…
devondragon Jun 13, 2026
b9ce58e
fix(docs): make the Javadoc jar build (release blocker)
devondragon Jun 13, 2026
209ee86
fix(security): make library SecurityFilterChain coexist with addition…
devondragon Jun 13, 2026
f0ee8f0
feat(security): preserve and regenerate current session on password c…
devondragon Jun 14, 2026
55b5d8a
docs(migration): correct chain override model; document breaking changes
devondragon Jun 14, 2026
f3ca56c
fix(mfa): auto-unprotect configured factor entry-point URIs to preven…
devondragon Jun 14, 2026
7d05a8c
ci: target Java 21 + 25 for the Spring Boot 4.x build (drop Java 17)
devondragon Jun 14, 2026
2a8c707
test: stop global spring.profiles.active pollution that flaked Regist…
devondragon Jun 14, 2026
26f5f10
test: restore load-bearing global test profile; keep RegistrationGuar…
devondragon Jun 14, 2026
6c97fbd
fix(security): address PR #314 review feedback
devondragon Jun 14, 2026
589c3f2
test: isolate each Spring context to a unique in-memory H2 database
devondragon Jun 14, 2026
93ec181
ci: bump actions to latest majors (Node 24)
devondragon Jun 14, 2026
5a7578b
fix(security): address second-pass review (session DoS, NPE, log inje…
devondragon Jun 14, 2026
5649436
fix(security): third-pass review — token replay, GDPR atomicity, audi…
devondragon Jun 14, 2026
fb9694a
test(security): integration tests proving token-replay guard and GDPR…
devondragon Jun 14, 2026
2c939bb
fix(security): make self-proxied transactional persist methods protec…
devondragon Jun 14, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 57 additions & 0 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
name: Build & Test

on:
push:
branches: [ main ]
pull_request:
branches: [ main ]

permissions:
contents: read

jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
# Spring Boot 4.x requires Java 21+. The library compiles to Java 21 bytecode via the Gradle
# toolchain; we additionally verify runtime compatibility on Java 25 (current LTS). Both JDKs are
# installed; listing 21 last makes it the default JAVA_HOME, so Gradle runs on (and compiles with)
# Java 21 and discovers Java 25 for the runtime-only testJdk25 task.
- name: Set up JDK 21 (toolchain) and 25 (runtime)
uses: actions/setup-java@v5
with:
distribution: temurin
java-version: |
25
21
- name: Set up Gradle
uses: gradle/actions/setup-gradle@v6
- name: Build and test (compile + test on Java 21)
run: ./gradlew check --no-daemon
- name: Test on Java 25 (runtime compatibility)
run: ./gradlew testJdk25 --no-daemon
- name: Upload test reports
if: always()
uses: actions/upload-artifact@v7
with:
name: test-reports
path: build/reports/tests/

codeql:
runs-on: ubuntu-latest
permissions:
security-events: write
steps:
- uses: actions/checkout@v6
- uses: actions/setup-java@v5
with:
distribution: temurin
java-version: '21'
- uses: github/codeql-action/init@v4
with:
languages: java-kotlin
queries: security-extended
- name: Build
run: ./gradlew compileJava --no-daemon
- uses: github/codeql-action/analyze@v4
2 changes: 1 addition & 1 deletion .github/workflows/claude-code-review.yml
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ jobs:

steps:
- name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@v6
with:
fetch-depth: 1

Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/claude.yml
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ jobs:
actions: read # Required for Claude to read CI results on PRs
steps:
- name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@v6
with:
fetch-depth: 1

Expand Down
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -164,7 +164,7 @@ All configuration uses `user.*` prefix in application.yml. Key property groups:

## Testing

Tests use H2 in-memory database with JUnit 5 parallel execution. Key dependencies: Testcontainers, WireMock, GreenMail, AssertJ, REST Assured.
Tests use H2 in-memory database with JUnit 5 parallel execution. Key dependencies: AssertJ, spring-security-test, Testcontainers (MariaDB/PostgreSQL), Awaitility, ArchUnit, and H2.

### Custom Test Annotations (use these instead of raw Spring annotations)

Expand Down
40 changes: 37 additions & 3 deletions CONFIG.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,9 +36,26 @@ Welcome to the User Framework SpringBoot Configuration Guide! This document outl

## 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.
- **Max Query Results (`user.audit.maxQueryResults`)**: Maximum number of audit events returned from queries. Prevents memory issues with large logs. Defaults to `10000`.
- **Log File Path (`user.audit.logFilePath`)**: The path to the audit log file. If this path is not writable, the system falls back to the system temp directory.
- **Flush on Write (`user.audit.flushOnWrite`)**: Set to `true` for immediate log flushing on every write. Defaults to `false` for performance. See **Durability** below.
- **Flush Rate (`user.audit.flushRate`)**: The interval, in milliseconds, at which the buffered audit log is flushed to disk when `flushOnWrite=false`. Defaults to `30000` (30 seconds).
- **Max Query Results (`user.audit.maxQueryResults`)**: Maximum number of audit events returned from queries. The query service streams the active log file and retains only the most-recent `maxQueryResults` matching events in a bounded ring buffer, so query memory stays bounded regardless of file size. Defaults to `10000`.
- **Max File Size (`user.audit.maxFileSizeMb`)**: Maximum size, in megabytes, of the active audit log file before it is rotated. When exceeded, the active file is renamed to `<name>.1` (shifting existing archives up to `maxFiles`) and a fresh active file is opened. Set to `0` or a negative value to **disable rotation** (logs grow unbounded). Defaults to `10`. **Rotation is enabled by default** to prevent unbounded disk growth.
- **Max Files (`user.audit.maxFiles`)**: Maximum number of rotated archive files to retain (e.g. `user-audit.log.1` .. `user-audit.log.5`). The oldest archive beyond this count is deleted on rotation. Defaults to `5`.

### Durability

The file audit sink uses a buffered writer. With the default `flushOnWrite=false`, audit events are written to an in-memory buffer and flushed to disk periodically on the `flushRate` schedule. On a hard crash, JVM kill (SIGKILL), or power loss, **up to one `flushRate` interval of buffered audit events (plus any un-flushed buffer contents) can be lost**.

For compliance or security-critical deployments where no audit event may be lost, set `user.audit.flushOnWrite=true`. This flushes to disk after every event, eliminating the durability window at a per-write performance cost (under heavy load). Alternatively, lowering `flushRate` narrows the window without paying the full per-write cost.

### Query Scope

Audit queries (used by GDPR export and consent history) read only the **active** log file. Rotated archive files (`<name>.1`, `<name>.2`, ...) are not included in query results. If long-range historical queries are required, use a larger `maxFileSizeMb`/`maxFiles` window or a database-backed `AuditLogWriter`/`AuditLogQueryService`.

## JPA Auditing

- **Enable JPA Auditing (`user.jpa.auditing.enabled`)**: Controls whether the library enables Spring Data JPA auditing (`@EnableJpaAuditing`) and registers an `AuditorAware` that captures the current user from the Spring Security context for `@CreatedBy`/`@LastModifiedBy` fields. Defaults to `true`. Set to `false` if your application runs its own JPA auditing or supplies its own `AuditorAware` bean, so the library does not hijack it. This property is the primary opt-out, because the library's `@EnableJpaAuditing` resolves the auditor bean by name (`auditorProvider`).

## GDPR Compliance

Expand All @@ -65,6 +82,16 @@ user:
- **Account Lockout Duration (`spring.security.accountLockoutDuration`)**: Duration (in minutes) for account lockout.
- **BCrypt Strength (`spring.security.bcryptStrength`)**: Adjust the bcrypt strength for password hashing. Default is `12`.

### Token Security

Verification and password-reset tokens are **hashed at rest**. The raw token is only ever sent to the user in the emailed link; the database stores its hash. Lookups hash the incoming token and match by hash, with a transparent fallback to plaintext lookup so that any links issued before upgrading keep working until they expire. This requires no schema migration and no action from consuming applications.

- **Token Hash Secret (`user.security.tokenHashSecret`)**: Optional secret used to key the at-rest hashing (HMAC-SHA-256) of verification and password-reset tokens. If left unset, plain SHA-256 is used, which is adequate because tokens are high-entropy random values. Setting a secret (kept outside the database) adds defense-in-depth against a database-only compromise. Default: unset.
- **Password Reset Token Lifetime (`user.security.passwordResetTokenValidityMinutes`)**: Lifetime in minutes of a password reset token before it expires. Default is `1440` (24 hours).
- **Verification Token Lifetime (`user.registration.verificationTokenValidityMinutes`)**: Lifetime in minutes of a registration verification token before it expires. Default is `1440` (24 hours).

Only one active token per user is kept for each token type: requesting a new password reset or verification email invalidates the previous one.

## WebAuthn / Passkey Settings

Provides passwordless login using biometrics, security keys, or device authentication. **HTTPS is required** for WebAuthn to function.
Expand Down Expand Up @@ -138,6 +165,13 @@ user:

- **From Address (`spring.mail.fromAddress`)**: The email address used as the sender in outgoing emails.

### Mail Executor

Email is sent asynchronously (`@Async`) with retry/backoff. To prevent an SMTP outage from starving the shared application task executor that other
async features rely on, mail runs on its own dedicated, bounded executor bean named `dsMailExecutor` (core pool 2, max pool 4, queue capacity 50, with
a `CallerRunsPolicy` rejection handler that applies backpressure to the calling thread when the pool and queue are saturated). To change the sizing,
supply your own `dsMailExecutor` bean (a `ThreadPoolTaskExecutor`); the library's default backs off via `@ConditionalOnMissingBean(name = "dsMailExecutor")`.


## Role and Privileges

Expand Down
131 changes: 121 additions & 10 deletions MIGRATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,21 @@ plugins {
}
```

#### Passay upgraded to 2.0.0

The framework's transitive Passay dependency was upgraded from 1.x to **2.0.0**, which **relocated
several packages** (e.g. `org.passay.CharacterData` → `org.passay.data.CharacterData`,
`org.passay.CharacterRule` → `org.passay.rule.CharacterRule`). This only affects you if your
application **uses Passay directly** (e.g. for custom password rules):

- If you declared your own `org.passay:passay` dependency at a 1.x version, **remove the explicit
pin** (let it inherit 2.0.0 transitively) or bump it to `2.0.0`. Pinning an older version forces a
conflicting downgrade that breaks the framework's `PasswordPolicyService` at runtime
(`ClassNotFoundException: org.passay.data.CharacterData`).
- Update your own Passay imports to the new 2.0.0 package names.

Applications that do not use Passay directly need no changes.

### Step 3: Spring Security 7 Changes

Spring Boot 4.0 includes Spring Security 7, which has breaking changes from Spring Security 6.x.
Expand Down Expand Up @@ -266,30 +281,67 @@ If you have a custom `WebSecurityConfig` or extend the framework's security conf
2. **Update to lambda DSL style** (required in Spring Security 7)
3. **Review method security annotations** - `@PreAuthorize`, `@PostAuthorize` unchanged

**Example custom security configuration:**
#### SecurityFilterChain override model (4.x)

The library contributes its `SecurityFilterChain` through a dedicated auto-configuration with two important properties:

- **Ordered at low precedence.** The library's chain is registered with `@Order(Ordered.LOWEST_PRECEDENCE - 5)` — the same low precedence Spring Boot uses for its own default servlet security chain. This value is sourced from `SecurityFilterProperties.BASIC_AUTH_ORDER`; the constant was `SecurityProperties.BASIC_AUTH_ORDER` in Spring Boot 3.x and was relocated to `SecurityFilterProperties.BASIC_AUTH_ORDER` in Spring Boot 4.0 (still `Ordered.LOWEST_PRECEDENCE - 5`). The library's chain has **no `securityMatcher`**, so it is the catch-all: any consumer-supplied chain with a `securityMatcher` and a lower (higher-precedence) `@Order` is consulted first by Spring Security's `FilterChainProxy`, and unmatched requests fall through to the library's chain.
- **Backs off only on a same-named replacement.** The library's chain bean is named `securityFilterChain` and is annotated `@ConditionalOnMissingBean(name = "securityFilterChain")`. It backs off **only** when you define a `SecurityFilterChain` bean **named `securityFilterChain`** (an explicit full replacement). Defining additional, differently-named chains does **not** suppress it.

> **Behavior change vs. earlier 4.x pre-releases:** an earlier iteration used `@ConditionalOnMissingBean(SecurityFilterChain.class)` (type-based), which suppressed the entire library chain as soon as you defined *any* `SecurityFilterChain` — even a narrow one (e.g. a test-API or actuator chain). That silently left the library's URIs unprotected. The conditional is now **name-based** so the standard Spring Security multi-chain `@Order` layering pattern works as expected.

This gives you two ways to customize security:

**Option A — Add additional, narrower chains alongside the library's (recommended for most layering).**

Define your own `SecurityFilterChain` with a `securityMatcher` scoping it to a subset of requests and a higher-precedence (lower) `@Order`. Give it **any name other than `securityFilterChain`**. Both chains coexist: your chain handles its matched requests, and the library's catch-all chain keeps protecting everything else (login, registration, password reset, profile, etc.).

```java
@Configuration
@EnableWebSecurity
public class CustomSecurityConfig {
public class ApiSecurityConfig {

@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
@Order(1) // higher precedence than the library's catch-all chain
public SecurityFilterChain apiSecurityFilterChain(HttpSecurity http) throws Exception {
http
.securityMatcher("/api/**") // scopes this chain to /api/**
.authorizeHttpRequests(authz -> authz
.requestMatchers("/api/public/**").permitAll()
.anyRequest().hasRole("ADMIN"))
.csrf(csrf -> csrf.disable());
return http.build(); // bean name is "apiSecurityFilterChain" -> library chain stays active
}
}
```

**Option B — Fully replace the library's chain (you own all the rules).**

Define your own `SecurityFilterChain` bean **named `securityFilterChain`**. The library's chain backs off entirely and does **not** apply any of its rules; you are now responsible for protecting *all* URIs, including the framework's endpoints.

```java
@Configuration
@EnableWebSecurity
public class CustomSecurityConfig {

@Bean // bean name MUST be "securityFilterChain" to replace the library's chain
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(authz -> authz
// All patterns must start with /
.requestMatchers("/api/public/**").permitAll()
.requestMatchers("/api/admin/**").hasRole("ADMIN")
.anyRequest().authenticated()
)
.formLogin(form -> form
.loginPage("/user/login.html")
.permitAll()
);
// You must also permit/secure the framework's own URIs here,
// since the library chain no longer applies:
.requestMatchers("/user/registration", "/user/login", "/user/resetPassword").permitAll()
.anyRequest().authenticated())
.formLogin(form -> form.loginPage("/user/login.html").permitAll());
return http.build();
}
}
```

For most applications that only need to *add* a few rules, the simplest path is to rely on the library's chain and the `user.security.*` properties (`protectedURIs`, `unprotectedURIs`, `defaultAction`, etc.) rather than defining your own `SecurityFilterChain` at all.

### Custom User Services

If you extend `UserService` or implement custom user management:
Expand All @@ -298,6 +350,65 @@ If you extend `UserService` or implement custom user management:
2. **Password encoding** - Still uses BCrypt, no changes required
3. **User entity** - No schema changes required

#### Password hashing moved outside the transaction (perf)

To avoid holding a pooled DB connection during the deliberately slow bcrypt hash, password
hashing now runs *outside* the database transaction. As a result `registerNewUserAccount`,
`changeUserPassword`, and `setInitialPassword` are annotated `Propagation.NOT_SUPPORTED` and
delegate the actual DB write to short, separate transactions of their own.

**Consumer-facing behavior change:** these three methods no longer participate in a caller's
transaction. If you previously called one of them from inside your own `@Transactional`, that
outer transaction is now suspended for the call and the registration / password change commits
independently — **an outer rollback will not roll back the registration or password change.**

Most consumers call these methods from controllers (which are not transactional) and are
unaffected. If you depend on enlisting these operations in a surrounding transaction, you will
need to restructure that flow.

#### `UserEmailService` constructor gained a `TokenHasher` parameter (breaking for subclasses)

Verification and password-reset tokens are now hashed at rest. `UserEmailService` therefore takes
an additional `TokenHasher` constructor parameter. **If you subclass `UserEmailService`**, your
subclass constructor must accept and pass through the new parameter:

```java
public CustomUserEmailService(
MailService mailService,
UserVerificationService userVerificationService,
PasswordResetTokenRepository passwordTokenRepository,
ApplicationEventPublisher eventPublisher,
SessionInvalidationService sessionInvalidationService,
TokenHasher tokenHasher) { // <-- new parameter
super(mailService, userVerificationService, passwordTokenRepository,
eventPublisher, sessionInvalidationService, tokenHasher);
}
```

`TokenHasher` is a framework `@Component`, so it is available for injection. Consumers that do not
subclass `UserEmailService` are unaffected. The hashing is backward compatible at runtime: tokens
issued before the upgrade (stored in plaintext) are still resolved via a dual-read lookup and remain
usable until they expire.

#### Sessions on password change: current session is now preserved (OWASP)

A self-service password change (and removing a password to go passwordless) invalidates the user's
**other** sessions but, by default, now **preserves and regenerates the current session** rather than
logging the user out of the device they just used. This follows OWASP guidance (regenerate the
current session id, invalidate the rest) and is a friendlier default.

To restore the previous "invalidate every session, including the current one" behavior, set:

```yaml
user:
session:
invalidation:
keep-current-session-on-password-change: false
```

Token-based password **resets** (the forgot-password flow) are unaffected: there is no authenticated
current session to preserve, so all of the user's sessions are invalidated as before.

### Custom Controllers

If you have controllers that extend or work alongside framework controllers:
Expand Down
15 changes: 14 additions & 1 deletion PROFILE.md
Original file line number Diff line number Diff line change
Expand Up @@ -165,13 +165,26 @@ public class AppUserProfileService implements UserProfileService<AppUserProfile>

### Step 4: Create a Session Profile Manager

Create a session-scoped component to access the current user's profile:
Create a session-scoped component to access the current user's profile.

> **⚠️ IMPORTANT — `@Scope` is NOT inherited.** Spring does not propagate the `@Scope` declared on
> `BaseSessionProfile` to your subclass. If you annotate your subclass with only `@Component` (and omit
> `@Scope`), it becomes a **singleton shared across all HTTP sessions**, leaking one user's profile to every
> other user — a serious security bug. Always declare session scoping on the subclass itself, either with the
> explicit `@Scope` shown below, or with the convenience meta-annotation `@SessionScopedProfile`
> (`com.digitalsanctuary.spring.user.profile.session.SessionScopedProfile`), which carries `@Component` and the
> correct `@Scope` in a single annotation.

```java
// Option A — explicit @Scope:
@Component
@Scope(value = WebApplicationContext.SCOPE_SESSION, proxyMode = ScopedProxyMode.TARGET_CLASS)
public class AppSessionProfile extends BaseSessionProfile<AppUserProfile> {

// Option B (equivalent) — the convenience meta-annotation:
// @SessionScopedProfile
// public class AppSessionProfile extends BaseSessionProfile<AppUserProfile> {

// Add custom accessor methods for your application
public String getDisplayName() {
return getUserProfile() != null ? getUserProfile().getDisplayName() : null;
Expand Down
Loading
Loading