REST back-end template with many integrated components and enterprise features: RBAC, MFA, cache, plug-ins
Key features:
- Convention over code: HTTP error codes, Spring XML wiring by name, JAX-RS named parameters
- RBAC with URL based authorization and dynamic roles, API tokens
- Multi-factor authentication: TOTP authenticator applications and passkeys (WebAuthn)
- Exception to REST/HTTP code mapping
- Advanced JAX-RS validation
- Tuned Jackson configuration for minified payload and validation
- Test powered with CSV data load to/from JPA entities
- TDD ready with pre-built asserts with Mockito and WireMock, target is
100%code coverage - Optional encrypted properties support and database configuration with Jasypt
- Plug-in class loader with optional JAR signature verification
- Spring-Data extensions for performance, minimal code and exception handling
A Spring based REST architecture
- Spring Security/Web/Data
- Apache CXF (JAX-RS)
- JPA / Hibernate
- Hibernate Validator for JPA and JAX-RS
- Hazelcast / JCache
- JUnit, WireMock
Requirements
- Java 25
- Maven 3.9.16
Ligoj Bootstrap (org.ligoj.bootstrap) is a Spring + Apache CXF JAX-RS architecture template consumed by downstream Ligoj projects as libraries and parent POMs. The Maven parent chain goes module β parent/pom.xml β org.ligoj.parent:project (external repository): build profiles and plugin defaults live there, dependency versions live in parent/pom.xml.
# Build everything (unit + integration tests)
mvn clean package
# All features (w/o deployment)
mvn clean package -Psources,javadoc,github,sonatype,jacoco,test
# Compile modules quickly (with their dependencies), tests included
mvn -pl bootstrap-core,bootstrap-business -am test-compile
# Tests of one module
mvn test -pl bootstrap-core
# Single test class
mvn test -pl bootstrap-core -Dtest=PluginsClassLoaderTest
# Coverage (target is 100%)
mvn clean package -Pjacoco -Djacoco.includes="org.ligoj.bootstrap.*"
# Check dependency updates
mvn versions:display-dependency-updates -Pjacoco -Dmaven.version.ignore="(?i)^(.*[.-](alpha|beta|rc|M|B|RC|pre|CR|jdk5)[.-]?[0-9]*|[0-9]{8}.*)$"Test failures do NOT fail the build: the external parent POM (org.ligoj.parent:project) sets testFailureIgnore=true for both surefire and failsafe. BUILD SUCCESS is meaningless for tests β always check the Tests run: β¦ Failures: β¦ Errors: β¦ lines or target/surefire-reports/.
Test naming splits unit vs integration: *Test = unit/Spring test, *IT = integration test that boots the real application on Jetty (port 6380). bootstrap-business runs them as two surefire executions via UTSuite/ITSuite (class-name regex suites); elsewhere failsafe runs *IT only with -Pit.
.mvn/jvm.config passes --sun-misc-unsafe-memory-access=allow β required for the build JVM.
Dependency layering: bootstrap-core β bootstrap-business, plus bootstrap-core β bootstrap-business-test (test scope dependency of bootstrap-business).
- bootstrap-core β foundation library, no Spring Boot/CXF server. Bean & JPA base classes (
AbstractPersistable,AbstractAudited), system entities (org.ligoj.bootstrap.model.system.*, tables prefixedS_), CSV engine, plug-in SPI +PluginsClassLoader, Jasypt crypto, TOTP/WebAuthn helpers, Hibernate naming strategies, custom validators. - bootstrap-business β the REST/JPA runtime: CXF wiring, exception mappers, Jackson config, Spring Data extensions, RBAC filters, Hazelcast/JCache, REST resources:
/system(server information, time zone) and/system/**(configuration, cache, security, user, user settings, API tokens, MFA, hooks, files, bench),/session. - bootstrap-business-test β test-support library (compile-scope deps): the
Abstract*Testhierarchy, HSQLDB Spring contexts, RBAC CSV fixtures, and the embedded Jetty launcherhttp.server.Mainused by*IT. - bootstrap-business-parent β pom-only parent consumed by downstream back-end projects (
org.ligoj.app:app-api); it preconfigures dependencies and resource filtering. - parent β BOM and shared build configuration of all the modules above.
Spring wiring is XML-first: contexts live at src/*/resources/META-INF/spring/*.xml and compose via <import resource="classpath*:/META-INF/spring/β¦"/> so plug-in JARs can contribute. core-context-common.xml holds the single component-scan (org.ligoj.bootstrap); default-autowire="byName" is used pervasively β bean/field names matter.
Package conventions (root is always org.ligoj.bootstrap): model.system = JPA entities, dao.system = Spring Data repositories, resource.system.<feature> = JAX-RS resources under /system/**, core.* = reusable technical code. One deliberate exception: org.eclipse.jetty.util.resource.VisibleCombinedResource in bootstrap-business-test (package-private Jetty API access).
Spring Data extensions: all repositories in org.ligoj get RestRepository methods (findOneExpected, deleteAllExpected, β¦) through RestRepositoryFactoryBean. jqGrid/DataTables JSON filters translate to Criteria via PaginationDao/DynamicSpecification; grid responses use TableItem/PaginationJson.
Exception β HTTP mapping: @Provider mappers in bootstrap-business/.../core/resource/mapper/ (registered in rest-context-common.xml) serialize a uniform ServerError JSON body. E.g. BusinessException/ValidationJsonException β 400, EntityNotFoundException β 404, DataIntegrityViolationException β 412, FailSafeExceptionMapper β 500 with message stripped. @OnNullReturn404 turns a null JAX-RS return into 404.
Jackson: the single objectMapper bean is ObjectMapperTrim (NON_NULL, lower-cased enums, custom date/time (de)serializers registered in its internal BootstrapModule). The codebase is on Jackson 3 (tools.jackson.* packages) with jackson-annotations still 2.x β watch package names when touching JSON code.
RBAC: SystemAuthorization rows hold role + HTTP method (null for all methods) + URL regex (type API or UI). AuthorizingFilter (last Spring Security filter) matches request path+method against a JCache-cached structure built by AuthorizationResource. API patterns apply to the decoded path without the context path (such as rest/system/user), from its start: ^rest/system/ and rest/system/ are equivalent, and a pattern never matches in the middle of the path. A role holding the .* API pattern for all methods makes its users administrators (SecurityHelper.ADMIN authority, $admin). Role names starting with $ are reserved, and $admin is never granted by a role or a provider having this name. Administrators are required by API delegation and by the administrator endpoints, such as the settings of another user, checked in code and with @Secured(SecurityHelper.ADMIN). RbacUserDetailsService loads roles (cache user-details). Each node keeps a local snapshot of the authorizations and of the hooks, reloaded only when these cluster caches change, and the security caches are cleared again after the commit of the changing transaction. Authentication is pre-authenticated header based: a principal header (SM_UNIVERSALID in the test contexts) + x-api-key via ApiTokenAuthenticationFilter. The Spring Security context itself is provided by the consuming application, see Deployment security requirements.
MFA: MfaResource (/system/mfa) manages the devices of the current user β TOTP authenticator applications and passkeys β and verifies codes and assertions. Enforcing the second factor after the primary authentication is the front-end's responsibility; server-side code can also check MfaResource.getVerifiedDate(login) (also exposed as verifiedDate by GET /system/mfa): the last successful verification, kept in the cluster-wide mfa-verified cache until the next POST /system/mfa/login or 12 hours (cache.mfa-verified.ttl). This state is per user, not per session. Passkeys require ligoj.mfa.rp-id (and optionally the accepted origins) to be set in production. Code verification (POST /system/mfa/verify, TOTP) is locked after ligoj.mfa.max-attempts consecutive attempts without success (default 5), counted atomically and shared by the cluster, until 15 minutes after the last attempt (cache.mfa-attempts.ttl). Passkey assertions are not counted: they cannot be guessed. Passkey challenges are single-use and shared by the cluster for 5 minutes (mfa-challenges cache).
Plug-in system: PluginsClassLoader scans ${ligoj.home}/plugins/*.jar (default ~/.ligoj/plugins), keeps only the newest version per artifact, and loads classes child-first so a plug-in JAR overrides the same plug-in bundled in the application. ligoj.plugin.enabled=false disables the external plug-ins (safe mode). JAR signatures are verified against a truststore of trusted vendor certificates, ligoj.plugin.signature.truststore (default ${ligoj.home}/plugin-vendors.p12, password ligoj.plugin.signature.truststore.password, default changeit): every content entry must be signed, and the signer certificate must be intended for code signing (not a CA nor a TLS-only certificate) and be pinned in or chain to the truststore to be VERIFIED. With ligoj.plugin.signature.required=true, only the VERIFIED plug-ins are loaded, and none without a usable truststore (fail closed). These are system properties. Plug-ins implement FeaturePlugin (key format a:b:c); their installation lifecycle is driven by the consuming application, this project only provides the SPI, the class loader and the SystemPlugin entity.
Configuration/crypto: values may be Jasypt-encrypted as ENC(...). The password is the first defined among: the app.crypto.password system property, the same key in the loaded properties files, the APP_CRYPTO_PASSWORD environment variable; then the content of the file named by app.crypto.file (system property, properties files) or APP_CRYPTO_FILE, also looked up in the classpath. See core-context-common.xml and the configuration reference.
ApiTokenAuthenticationFilter authenticates requests from headers:
| Request headers | Authenticated user |
|---|---|
principal header + x-api-key |
the principal, when the token belongs to this user |
principal header + x-api-key + x-api-via-user |
the principal, when the token belongs to the x-api-via-user user, and this user is an administrator (delegation) |
| principal header only | the principal, without any check |
The last row is the SSO mode: an authenticating reverse proxy sets the principal header after its own authentication. It is only safe when:
- the application port is never reachable without going through the proxy (private interface or network, firewall);
- the proxy removes or overwrites the principal header of every client request, so it cannot be forged;
- the proxy removes the
x-api-via-userandx-api-local-rolesclient headers, unless API delegation is used.
Otherwise, any client sending SM_UNIVERSALID: admin is authenticated as admin. These requirements apply to every application built on this project.
The consuming application should also enable the method security (<security:method-security secured-enabled="true"/>) for the @Secured annotations; the administrator endpoints of this project also check the administrator authority in code.
Three sources, depending on the key:
- Runtime keys are read by
ConfigurationResource: the SpringEnvironment(system properties, environment variables, and the Spring Boot property sources of the consuming application), then theS_CONFIGURATIONtable, managed with/system/configuration. - Placeholder keys (
${...}in the Spring XML contexts and@Value) also come fromadmin.confidential.properties,maven-buildinfo.propertiesandapplication${app-env}.propertiesin the classpath, loaded byGlobalPropertyUtils. - System keys are JVM system properties only.
| Key | Default | Source | Usage |
|---|---|---|---|
app-env |
empty | system | Suffix of the application${app-env}.properties file |
app.crypto.password, app.crypto.file |
none | system, properties, env (APP_CRYPTO_PASSWORD, APP_CRYPTO_FILE) |
Jasypt password, see above |
ligoj.home |
~/.ligoj |
system | Home directory, plug-ins in plugins/ |
ligoj.plugin.enabled |
true |
system | false: safe mode, no external plug-in |
ligoj.plugin.signature.required |
false |
system | Only the VERIFIED plug-ins are loaded |
ligoj.plugin.signature.truststore |
${ligoj.home}/plugin-vendors.p12 |
system | Trusted vendor certificates (PKCS12, or JKS by extension) |
ligoj.plugin.signature.truststore.password |
changeit |
system | Truststore password |
ligoj.mfa.issuer |
Ligoj |
runtime | Issuer shown by authenticator applications, passkey relying party name |
ligoj.mfa.rp-id |
localhost |
runtime | Passkey relying party identifier: must be set in production, never changed afterwards |
ligoj.mfa.origins |
empty: HTTPS origins of the rp-id host and sub-domains (HTTP on localhost) | runtime | Comma separated accepted passkey origins |
ligoj.mfa.max-attempts |
5 |
runtime | Code verification attempts before the lock |
ligoj.hook.path |
^$ (none) |
runtime | Comma separated regular expressions of the allowed hook commands |
ligoj.hook.timeout |
30 (seconds) |
runtime, system | Process timeout of the hooks defining a zero timeout (a hook without timeout gets 10 seconds); the process is destroyed at the timeout |
ligoj.file.path |
^$ (none) |
runtime | Comma separated regular expressions of the allowed /system/file paths, matched against the canonical path |
cache.<name>.ttl |
per cache, see below | Environment, at startup |
Cache entry lifetime in seconds, -1 for eternal |
cache.<name>.cron |
none | Environment, at startup |
Fixed expiry times of the cache entries, as a Spring CRON expression in the JVM time zone, such as 0 0 3 * * * for every day at 03:00: an entry expires at the first time after its creation or update (CronExpiryPolicy). Takes precedence over cache.<name>.ttl; an invalid expression is logged and the TTL applies |
cache.location |
classpath:META-INF/hazelcast-local.xml |
placeholder | Hazelcast configuration |
hazelcast.statistics.enable |
false |
placeholder | Cache statistics |
security.filter.methods |
GET,POST,DELETE,PUT |
placeholder | HTTP methods granted by an authorization without method |
pagination.max-size |
1000 |
placeholder | Maximal page size a client can request; larger, zero or negative sizes are replaced by it |
api.token.secret |
built-in value: override it | placeholder | Secret of the API token encryption |
api.token.length, api.token.iterations, api.token.digest, api.token.crypt |
128, 31, SHA-512, DESede |
placeholder | API token generation, hash and encryption |
api.token.purge |
0 0 4 * * ? |
placeholder | Cron of the expired API tokens purge |
ligoj.sslVerify |
constructor argument | system | false disables the TLS checks of all the CurlProcessor instances |
https.proxyHost, https.proxyPort |
constructor arguments, port 8080 |
system | Proxy of the CurlProcessor instances |
jdbc.driverClassName, jdbc.url, jdbc.username, jdbc.password, jdbc.maxPoolSize |
required | placeholder | Data source (jpa-context.xml) |
jdbc.validationQuery |
SELECT 1 |
placeholder | Connection validation |
jpa.dialect |
org.ligoj.bootstrap.core.dao.MySQL5InnoDBUtf8Dialect |
placeholder | Hibernate dialect |
jpa.hbm2ddl, jpa.showSql, jpa.schema, jpa.log_queries_slower_than_ms, jpa.generate_statistics |
none, false, empty |
placeholder | Hibernate settings |
spring.data.jpa.repositories.bootstrap-mode |
lazy |
placeholder | Spring Data repositories initialization |
ligoj.name |
Ligoj |
placeholder | Application name of the session settings |
Caches (Hazelcast JCache, shared by the cluster), with their default lifetime: authorizations, hooks, configuration (eternal, cleared on change), user-details (1 hour), api-tokens (valid API token checks, 1 minute, cleared on any token change), mfa-attempts (15 minutes after the last attempt), mfa-verified (12 hours, cleared by a new login), mfa-challenges (5 minutes). A change of the database not made through the resources of this project is seen at the end of these lifetimes.
Extend the chain in bootstrap-business-test (org.ligoj.bootstrap package): AbstractTest β AbstractDataGeneratorTest β AbstractSecurityTest β AbstractJpaTest β AbstractAppTest β AbstractServerTest (WireMock on port 8120). AbstractRestTest boots the real server for *IT. Canonical Spring test setup (see AbstractBootTest in bootstrap-business test sources):
@ExtendWith(SpringExtension.class)
@ContextConfiguration(locations = "classpath:/META-INF/spring/application-context-test.xml")
@Rollback @Transactionalapplication-context-test.xml is not shipped by bootstrap-business-test: each module and downstream project provides its own, importing the shipped jpa-context-test.xml, business-context-test.xml and rest-context-web.xml.
- DB is in-memory HSQLDB (
jpa-context-test.xml), which imports the productionjpa-context-common.xmlβ repositories/auditing behave as in production. Persistence unit name is alwayspu. - Test data loads from CSV via
CsvForJpa:persistEntities(SystemRole.class, "csv/system-test/role.csv")for one file, orpersistEntities("csv/root", Class...)which looks for<simplename-lowercase>.csvthen<simple-name-kebab-case>.csv(e.g.system-role.csv). Header row = property names, foreign keys as dotted paths (role.name,user.login!to look the key up in the database instead of the already loaded rows). AbstractAppTest.persistSystemEntities()seeds the RBAC tables; default principal isjunit(initSpringSecurityContext(...)to switch).src/main/resourcesandsrc/test/resourcesare Maven-filtered in bootstrap-business and downstream β literal${...}in resources will be substituted at build time.MatcherUtilasserts field/rule insideValidationJsonException;AbstractBusinessEntityTestcovers entity equals/hashCode reflectively.