Jakarta EE Security: Using Identity Stores
Jakarta EE Security (formerly JSR 375) introduces the notion of identity stores. Here, learn how they are implemented by Jakarta EE platforms like Payara.
Join the DZone community and get the full member experience.
Join For FreeAs one of the most important aspects of modern business applications and services, the security of the Java enterprise-grade applications didn't wait for the Jakarta EE 10 outbreak. Starting from the first releases of J2EE in early Y2K, security was the crux of enterprise software architecture. It evolved little by little with the gradual development of specifications, but the JSR-375 as we know it today appeared a couple of years ago with Jakarta EE 8, under the name of Java EE Security API 1.0. The current release of the Jakarta EE 10 comes with a major update of Java EE Security API under its new name: Jakarta Security 3.0.
The Jakarta Security specifications are organized around a new terminology defined by the following new concepts:
- Authentication mechanisms: Invoked by callers to obtain their credentials and to validate them against the existing ones in identity stores
- Caller: Principal (user or service) originating a call to the API
- Identity store: Software component that controls access to the API through credentials, roles groups, and permissions
- Jakarta Authorization (formerly JSR-115: JACC - Java Authorization Contracts for Containers)
- Jakarta Authentication (formerly JASPIC - Java Authentication SPI for Containers)
The concept of authorization mechanism, as defined by the Jakarta Security specifications, designates controllers that interact with a caller and a container environment to obtain credentials, validate them, and pass an authenticated identity (such as users of group names) to the container. In order to validate the credentials, the authorization mechanisms use identity stores. The specifications define built-in identity stores for files, RDBMS (Relational Data Base Management System) and LDAP (Lightweight Directory Access Protocol) servers, in addition to fully customized ones.
In this blog, we'll look at how to secure Java web applications using Jakarta Security built-in RDBMS and LDAP-based identity stores. We chose Payara as the Jakarta EE platform to illustrate this, but the process should be the same, whatever the Jakarta EE-compliant implementation might be.
A Common Use Case
The project that serves to exemplify our storyline can be found here. It is structured as a maven
project having a separate module for each of the demonstrated built-in identity stores, as follows:
- An aggregator POM called
jsr-375
- A WAR artifact called
servlet-with-ldap-identity-store
, demonstrating the LDAP built-in identity store - A WAR artifact called
servlet-with-jdbc-identity-store
, demonstrating the LDAP built-in identity store - An infrastructure project called
platform
, which relies ontestcontainers
in order to run two instances of the Payara Platform, a Server and a Micro, each one having deployed to it the two WARs referenced above
The Infrastructure
As explained above, our sample application is deployed on the Payara Server as well as on the Payara Micro. In order to do this, we're running two Docker containers: one for the Payara Server instance and one for the Payara Micro one. We need to orchestrate these containers; hence, we'll be using the docker-compose
utility. Here is an excerpt of the associated YAML file:
version: '3.6'
services:
payara-micro:
container_name: payara-micro
image: payara/micro:latest
ports:
- 28080:8080
- 26900:6900
expose:
- 8080
- 6900
volumes:
- ../../../../servlet-with-ldap-identity-store/target/servlet-with-ldap-identity-store.war:/opt/payara/deployments/servlet-with-ldap-identity-store.war
- ../../../../servlet-with-jdbc-identity-store/target/servlet-with-jdbc-identity-store.war:/opt/payara/deployments/servlet-with-jdbc-identity-store.war
payara-full:
container_name: payara-full
image: payara/server-full:latest
ports:
- 18080:8080
- 18081:8081
- 14848:4848
- 19009:9009
expose:
- 8080
- 8081
- 4848
- 9009
volumes:
- ../../../../servlet-with-ldap-identity-store/target/servlet-with-ldap-identity-store.war:/opt/payara/deployments/servlet-with-ldap-identity-store.war
- ../../../../servlet-with-jdbc-identity-store/target/servlet-with-jdbc-identity-store.war:/opt/payara/deployments/servlet-with-jdbc-identity-store.war
- ./scripts/init.sql:/opt/payara/init.sql
As we can see in the docker-compose.yaml
file above, the following services are started as Docker containers:
- A service named
payara-micro
listening for HTTP connexions on the TCP port 28080 - A service named
payara-full
listening for HTTP connexions on the TCP port 18080
Note that the two Payara services are mounting WARs to the container's deployment directory. This has the effect of deploying the given WARs.
Note also that the service payara-full
- which runs the Payara Server and, consequently, hosts the H2 database instance - also mounts the SQL script init.sql
, which will be run in order to create and initialize the H2 schema required for the use of our identity store. Accordingly, it is the H2 database instance hosted by the Payara Server that will be used by both payara-full
and payara-micro
services.
In order to run the docker-compose
commands to start these services we're using the docker-compose-maven-plugin
. Here is an excerpt of the associated POM:
...
<plugin>
<groupId>com.dkanejs.maven.plugins</groupId>
<artifactId>docker-compose-maven-plugin</artifactId>
<inherited>false</inherited>
<executions>
<execution>
<id>up</id>
<phase>install</phase>
<goals>
<goal>up</goal>
</goals>
<configuration>
<composeFile>${project.basedir}/src/main/resources/docker-compose.yml</composeFile>
<detachedMode>true</detachedMode>
<removeOrphans>true</removeOrphans>
</configuration>
</execution>
<execution>
<id>down</id>
<phase>clean</phase>
<goals>
<goal>down</goal>
</goals>
<configuration>
<composeFile>${project.basedir}/src/main/resources/docker-compose.yml</composeFile>
<removeVolumes>true</removeVolumes>
<removeOrphans>true</removeOrphans>
</configuration>
</execution>
</executions>
</plugin>
...
Here we bind the up
operation to the install
phase and the down
one to the clean
phase. This way we'll get the containers running by executing mvn install
and we'll stop and remove them with mvn clean
.
The RDBMS Identity Store
The module servlet-with-jdbc-identity-store
, which is the one that interests us here, is organized around the following classes:
JdbcIdentityStoreConfig
: This is the configuration class.JdbcIdentitySoreServlet
: This is a servlet demonstrating the database identity stored-based authentication.JdbcSetup
: This class is setting up the identity store required schema.
Let's have a more detailed view of each of these classes.
The Class JdbcIdentityStoreConfig
This class defines the configuration of our RDBMS identity store. The idea behind the RDBMS identity store is that the principal-related information is stored in a relational database. In our example, this database is the H2 instance that comes with the Payara Platform. This is an in-memory database, used here for the sake of simplicity. Of course, such a design shouldn't be reproduced in production where more production-ready databases, like Oracle, PostgreSQL, or MySQL should be used. In any case, the H2 schema is created and initialized by the JdbcSetup
class, as it will be explained in a moment.
The listing below shows an excerpt of the code:
@ApplicationScoped
@BasicAuthenticationMechanismDefinition(realmName="admin-realm")
@DatabaseIdentityStoreDefinition(
dataSourceLookup = "${'java:global/H2'}",
callerQuery = "select password from caller where name = ?",
groupsQuery = "select group_name from caller_groups where caller_name = ?"
)
public class JdbcIdentityStoreConfig{}
As we can see, our class is a CDI (Context and Dependency Injection) bean, having the application scope. The annotation @DatabaseIdentityStoreDefinition
is the new Jakarta EE one, defining the database identity store mechanism. The argument named dataSourceLookup
declares a JNDI (Java Name and Directory Interface) lookup name which will bring the associated data source definition. Once this data source reference is found, we'll execute the two defined SQL queries, callerQuery
and groupsQuery
, in order to find the caller
credentials; i.e., its identifier and password, as well as its group membership. The notion of caller
here is somehow equivalent to the one of user
: while being a less human connotation, as it could also be a service. Hence, the use of the pronoun "it."
But the most interesting thing to be noticed is the fact that we're using here the HTTP basic authentication mechanism, defined by the @BasicAuthenticationMechanismDefinition
annotation. This means that at the application startup, we'll be presented with a login screen and challenged to authenticate with a username and password. This information will be further transmitted to the database identity store mechanism which will compare them with the ones stored in the database. This way we're composing two JSR-375 security features, the HTTP basic authentication associated with the database Identity Store. I'm leaving to the sovereign appraisal of the reader this facility which saves several dozens of lines of code.
The Class JdbcIdentityStoreServlet
This class is a servlet to which access is authorized to any caller having the role of admin-role
. This is defined through the Jakarta EE annotation @ServletSecurity
. Another specific Jakarta EE annotation is @DeclareRoles
which allows for the enumeration of all the possible roles that the application should be aware of.
The Class JdbcSetup
This class is responsible for the creation and initialization of the data model required by the database identity store mechanism. In its @PostConstruct
method, it creates two database tables named caller
and, respectively, caller_groups
. Then, these tables are initialized with caller names, passwords, and group names. The caller named admin
is then attached to the groups admin-role
and user-role
while the caller named user
is only a member of the group user-roles
.
It should be noted that the password is stored in the database as hashed. The Jakarta Security specifications define the interface Pbkdf2PasswordHash
having a default implementation based on the PBKDF2WithHmacSHA256
algorithm. This implementation can be simply injected, as you can see, in the associated source code. Here we are using the default implementation which is largely satisfactory for our example. Other more secure hash algorithms may be used as well, and, in this case, the Pbkdf2PasswordHash
default implementation may be initialized by passing to it a map containing the algorithm name as well as parameters like the salt, the number of iterations, etc. The Jakarta EE documentation presents all these details in extenso.
Another thing to mention is the fact that using Java JDBC (Java Data Base Connectivity) code to initialize the database in a runtime singleton @PostConstruct
method is probably not the most elegant way to deal with SQL. The in-memory H2 database used here accepts on its JDBC connection string the argument named "run script from," allowing us to define the script to be run in order to initialize the database. Accordingly, instead of doing that in Java JDBC code and having to provide a dedicated EJB (Enterprise JavaBeans) for this purpose, we could have had an initialization SQL script run automatically at the deployment time. Additionally, in order to deal with the password hashing, we could have dealt with the HASH
function that H2 provides in its more recent releases. However, the Payara Platform comes with an older release of the H2 database, which doesn't support this feature. Accordingly, to save ourselves the burden of having to upgrade the H2 database release that comes with the Payara Platform, we finally preferred this simpler alternative.
Running the Example
In order to run our example, proceed as follows:
- Execute the command
mvn clean install
. This command will stop the Docker containers, if they are running, and starts new instances. It also will run the integration tests that should succeed. - The integration test already tested the service in a Docker container started with
testcontainers
. But you can now test it on more production-ready containers, like the one managed by the platform Maven module. You can run commands like the below ones to test on Payara Server and, respectively, on Payara Micro:
curl http://localhost:18080/servlet-with-jdbc-identity-store/secured -u "admin:passadmin"
curl http://localhost:28080/servlet-with-jdbc-identity-store/secured -u "admin:passadmin"
The LDAP Identity Store
Using relational databases to store security principal-related information is a quite common practice; however, these databases aren't exactly the right tool for such use cases. More often than not, organizations use Microsoft ActiveDirectory to store users, groups, and roles-related information together with their associated credentials and other information. While we could have used in our example ActiveDirectory or any other similar LDAP implementation (for example, Apache DS), such an infrastructure would have been too heavy and complex. Hence, in order to avoid that, we preferred to use an in-memory LDAP server.
There are several open-source LDAP in-memory implementations, among which one of the most suitable is UnboundID LDAP SDK for Java. In order to use it, all we need is a dedicated Maven plugin, as shown below:
<dependency>
<groupId>com.unboundid</groupId>
<artifactId>unboundid-ldapsdk</artifactId>
</dependency>
We also need to define our schema in an LDIF (LDAP Data Interchange Format) file that will be loaded into the in-memory directory. For example, we define two principals named admin
and, respectively, user
. The admin
principal has the roles admin-role
and user-role
, while the user
principal has only the user-role
one. Here is the required LDIF notation:
...
dn: uid=admin,ou=caller,dc=payara,dc=fish
objectclass: top
objectclass: uidObject
objectclass: person
uid: admin
cn: Administrator
sn: Admin
userPassword: passadmin
dn: uid=user,ou=caller,dc=payara,dc=fish
objectclass: top
objectclass: uidObject
objectclass: person
uid: user
cn: User
sn: User
userPassword: passuser
...
dn: cn=admin-role,ou=group,dc=payara,dc=fish
objectclass: top
objectclass: groupOfNames
cn: admin-role
member: uid=admin,ou=caller,dc=payara,dc=fish
dn: cn=user-role,ou=group,dc=payara,dc=fish
objectclass: top
objectclass: groupOfNames
cn: user-role
member: uid=admin,ou=caller,dc=payara,dc=fish
member: uid=user,ou=caller,dc=payara,dc=fish
...
The module servlet-with-ldap-identity-store
, which is the one that interests us here, is organized around the following classes:
LdapIdentityStoreConfig
: This is the configuration class.LdapIdentitySoreServlet
: This is a servlet demonstrating the database identity store-based authentication.LdapSetup
: This class is setting up the identity store required schema.
Let's have a more detailed view of each of these classes.
The Class LdapIdentityStoreConfig
This class defines the configuration of our LDAP-based identity store. Here is a code excerpt:
@ApplicationScoped
@BasicAuthenticationMechanismDefinition(realmName="admin-realm")
@LdapIdentityStoreDefinition(
url = "ldap://localhost:33389",
callerBaseDn = "ou=caller,dc=payara,dc=fish",
groupSearchBase = "ou=group,dc=payara,dc=fish")
public class LdapIdentityStoreConfig{}
As already mentioned, we're using the HTTP basic authentication. This is quite convenient as the browser will display a login screen allowing you to type in the user name and the associated password. Furthermore, these credentials will be used in order to authenticate against the ones stored in our LDAP service, listening for connections on the container's 33389 TCP port. The callerBaseDN
argument defines, as its name implies the distinguished name of the caller, while the groupSearchBase
one defines the LDAP query required in order to find the groups to which a user belongs.
The Class LdapIdentityStoreServlet
Our servlet is a protected one, authorized solely for principals having the role admin-role
.
@WebServlet("/secured")
@DeclareRoles({ "admin-role", "user-role" })
@ServletSecurity(@HttpConstraint(rolesAllowed = "admin-role"))
public class LdapIdentityStoreServlet extends HttpServlet
{
@Override
protected void doGet(HttpServletRequest request, HttpServletResponse response)
throws IOException
{
response.getWriter().write("This is a secured servlet \n");
Principal principal = request.getUserPrincipal();
String user = principal == null ? null : principal.getName();
response.getWriter().write("User name: " + user + "\n");
response.getWriter().write("\thas role \"admin-role\": " + request.isUserInRole("admin-role") + "\n");
response.getWriter().write("\thas role \"user-role\": " + request.isUserInRole("user-role") + "\n");
}
}
We're using the @WebServlet
annotation in order to declare our class as a servlet. The @ServletSecurity
annotation means here that only users having the role admin-role
are allowed.
The Class LdapSetup
Last but not least, the class LdapSetup
instantiates and initializes the in-memory LDAP service:
@Startup
@Singleton
public class LdapSetup
{
private InMemoryDirectoryServer directoryServer;
@PostConstruct
public void init()
{
try
{
InMemoryDirectoryServerConfig config = new InMemoryDirectoryServerConfig("dc=fish");
config.setListenerConfigs(
new InMemoryListenerConfig("myListener", null, 33389, null, null, null));
directoryServer = new InMemoryDirectoryServer(config);
directoryServer.importFromLDIF(true,
new LDIFReader(this.getClass().getResourceAsStream("/users.ldif")));
directoryServer.startListening();
} catch (LDAPException e)
{
throw new IllegalStateException(e);
}
}
@PreDestroy
public void destroy()
{
directoryServer.shutDown(true);
}
}
This is a Startup
(runs automatically at the start-up) CDI bean, having the Singleton
scope. It instantiates the in-memory directory server by loading the LDIF file shown above and starts listening for LDAP requests on the TCP port number 33389 of the localhost
.
Testing
An integration test is provided to be executed with the failsafe
Maven plugin. During Maven's integration test phase, this integration test uses testcontainers
to create a Docker container running a Payara Micro image and deploy to it our WAR. Here is an excerpt from the integration test with testcontainers
:
@Container
private static GenericContainer payara =
new GenericContainer("payara/micro:latest")
.withExposedPorts(8080)
.withCopyFileToContainer(MountableFile.forHostPath(
Paths.get("target/servlet-with-ldap-identity-store.war")
.toAbsolutePath(), 0777), "/opt/payara/deployments/test.war")
.waitingFor(Wait.forLogMessage(".* Payara Micro .* ready in .*\\s", 1))
.withCommand(
"--noCluster --deploy /opt/payara/deployments/test.war --contextRoot /test");
Here we create a Docker container running the image payara/micro:latest
and exposing the TCP port 8080. We also copy to the image the WAR that we just built during Maven's package phase, and, finally, we start the container. Since Payara Micro might need a couple of seconds in order to start, we need to wait until it has fully booted. There are several ways to wait for the server boot to complete but here we use the one consisting of scanning the log file until a message containing "Payara Micro is ready" is displayed.
Last but not least, testing the deployed servlet is easy using the REST assured library, as shown below:
@Test
public void testGetSecuredPageShouldSucceed() throws IOException
{
given()
.contentType(ContentType.TEXT)
.auth().basic("admin", "passadmin")
.when()
.get(uri)
.then()
.assertThat().statusCode(200)
.and()
.body(containsString("admin-role"))
.and()
.body(containsString("admin-role"));
}
Running
In order to run the applications proceed as follows:
- Execute the command
mvn clean install
. This command will stop the Docker containers, if they are running, and starts new instances. It also will run the integration test that should succeed. - The integration test already tested the service in a Docker container started with
testcontainers
. But you can now test it on more production-ready containers, like the one managed by the platform Maven module. To test on the Payara Server, you can run commands like this:
curl http://localhost:18080/servlet-with-ldap-identity-store/secured -u "admin:passadmin"
Run the below commands to test on Payara Micro.
curl http://localhost:28080/servlet-with-ldap-identity-store/secured -u "admin:passadmin"
Enjoy!
Published at DZone with permission of Nicolas Duminil. See the original article here.
Opinions expressed by DZone contributors are their own.
Comments