Jakarta Security and REST in the Cloud: Part 2 Getting to Know the Basics
Jakarta Security and REST in the Cloud: Part 2 Getting to Know the Basic
Join the DZone community and get the full member experience.
Join For FreeSecurity is generally a topic that we always leave out when we talk about software architecture, but that does not mean that it is not essential. To talk more about the subject, we created this series on Java API security with Jakarta EE. In this second part, we will speak of the BASIC authentication process, how to implement it with a non-relational database, in this case, MongoDB, and how to quickly take it to the cloud.
BASIC is an authentication process where the user needs to enter their respective credentials, such as username and password, through each request in the header when they want to make a request to the server. In this case, the request header will have an Authorization: Basic <credential> where the credential is the username and password separated by “:” encoded using Base 64.
The great advantage of this mechanism is found in the simplicity of the implementation. However, there are some problems:
In each request, it is necessary to pass the user's credentials and sensitive information. The less sensitive information is sent, the better.
Contrary to what many people imagine, Base 64 is not an encryption, and it is very easy to decode it to find the user's data from it.
Thinking of a distributed architecture with microservices, all requests will have to carry out a validation to authenticate and authorize the user, which may result in an overload in the security service.
Again, thinking about a distributed environment with microservices, if the first request needs more information from other services, you will always have to pass the critical information to the premises. This always enhances the chance of a man-in-the-middle attack and someone capturing that information. Hence, the importance of using secure communication.
Having explained some of the advantages and disadvantages of using BASIC as a mechanism, we will continue the article by creating integration with a database. In this case, we will use a NoSQL database, MongoDB. In a very simple way, we will use the previous example as a base, so we will create a new implementation of IdentityStore. However, instead of leaving the information in memory, we will get it from the database Dice.
Based on our Hello World example using the security API, we will add the mechanism to our dependencies to work with and integrate with the database. Since we are working with both Jakarta and NoSQL, our best option is undoubtedly Jakarta NoSQL.
<dependency>
<groupId>org.eclipse.jnosql.artemis</groupId>
<artifactId>artemis-document</artifactId>
<version>${jnosql.version}</version>
</dependency>
<dependency>
<groupId>org.eclipse.jnosql.diana</groupId>
<artifactId>mongodb-driver</artifactId>
<version>${jnosql.version}</version>
</dependency>
Starting with the code, indeed, the first step is in modeling. For that reason, we will create our user with the minimum of necessary fields; in this case, the nickname, password, and roles that the user will have:
xxxxxxxxxx
import jakarta.nosql.mapping.Column;
import jakarta.nosql.mapping.Entity;
import jakarta.nosql.mapping.Id;
import javax.security.enterprise.identitystore.Pbkdf2PasswordHash;
import java.util.Collections;
import java.util.HashSet;
import java.util.Set;
import java.util.stream.Collectors;
import static java.util.Objects.requireNonNull;
public class User {
private String name;
private String password;
private Set<Role> roles;
//...
}
A significant thing about the password is that we should never store it directly. The best option will always be to save the password hash and compare it. In terms of code design, a question arises:
How do we create an instance and update the user's password? Leaving the public attribute, we already know that it is not a way out. But what if we use the classic getter and setter? To answer this question, I will use another one: Is there any guarantee that whoever uses the setter will handle the hash before modifying it?
The answer is simple: no. This demonstrates that there are encapsulation problems, even using the private-public getter and setter attributes. To ensure that every time the user updates the password, it will not be saved without the hash, we will create an update method from which we will pass Pbkdf2PasswordHash
, and we will leave all the work to this dependency. The goal is to demonstrate that a rich model helps to create a fail-safe API different from a simple model.
xxxxxxxxxx
void updatePassword(String password, Pbkdf2PasswordHash passwordHash) {
this.password = passwordHash.generate(password.toCharArray());
}
Another point is that we kept the method visible only to the package. It is vital to point out that the lower the visibility, the better. It is something that Java makes very clear about minimizing the accessibility of the fields, and it is worth following it.
With the update method completed, how will we do the insertion? This is simpler. We can use the Builder pattern, moving the creation logic, including the hash logic in the class. So we maintained the principle of SOLID's sole responsibility as well as avoiding the lack of encapsulation.
xxxxxxxxxx
User user = User.builder()
.withPasswordHash(passwordHash)
.withPassword(password)
.withName(name)
.withRoles(roles)
.build();
An important point to be emphasized is that it is useless to be careful with storing the password if this information, even with the hash, ends up leaking. Thus, we need to be careful when using notation to ignore the serialization of this information or to make explicit the information that will be transmitted by the service with a DTO layer.
In this integration, we also need to have a service to create and change users, and we will do this with the SecurityService
class, as shown in the code below:
xxxxxxxxxx
import javax.enterprise.context.ApplicationScoped;
import javax.inject.Inject;
import javax.security.enterprise.SecurityContext;
import javax.security.enterprise.identitystore.Pbkdf2PasswordHash;
import java.security.Principal;
import java.util.Collections;
import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;
class SecurityService {
private UserRepository repository;
private Pbkdf2PasswordHash passwordHash;
private SecurityContext securityContext;
void create(UserDTO userDTO) {
if (repository.existsById(userDTO.getName())) {
throw new UserAlreadyExistException("There is an user with this id: " + userDTO.getName());
} else {
User user = User.builder()
.withPasswordHash(passwordHash)
.withPassword(userDTO.getPassword())
.withName(userDTO.getName())
.withRoles(getRole())
.build();
repository.save(user);
}
}
void delete(String id) {
repository.deleteById(id);
}
void updatePassword(String id, UserDTO dto) {
final Principal principal = securityContext.getCallerPrincipal();
if (isForbidden(id, securityContext, principal)) {
throw new UserForbiddenException();
}
final User user = repository.findById(id)
.orElseThrow(() -> new UserNotFoundException(id));
user.updatePassword(dto.getPassword(), passwordHash);
repository.save(user);
}
public void addRole(String id, RoleDTO dto) {
final User user = repository.findById(id)
.orElseThrow(() -> new UserNotFoundException(id));
user.addRoles(dto.getRoles());
repository.save(user);
}
public void removeRole(String id, RoleDTO dto) {
final User user = repository.findById(id)
.orElseThrow(() -> new UserNotFoundException(id));
user.removeRoles(dto.getRoles());
repository.save(user);
}
public UserDTO getUser() {
final Principal principal = securityContext.getCallerPrincipal();
if (principal == null) {
throw new UserNotAuthorizedException();
}
final User user = repository.findById(principal.getName())
.orElseThrow(() -> new UserNotFoundException(principal.getName()));
UserDTO dto = toDTO(user);
return dto;
}
public List<UserDTO> getUsers() {
return repository.findAll()
.map(this::toDTO)
.collect(Collectors.toList());
}
private UserDTO toDTO(User user) {
UserDTO dto = new UserDTO();
dto.setName(user.getName());
dto.setRoles(user.getRoles());
return dto;
}
private Set<Role> getRole() {
if (repository.count() == 0) {
return Collections.singleton(Role.ADMIN);
} else {
return Collections.singleton(Role.USER);
}
}
private boolean isForbidden(String id, SecurityContext context, Principal principal) {
return !(context.isCallerInRole(Role.ADMIN.name()) || id.equals(principal.getName()));
}
}
There are two important points to note in the SecurityService class. The first is in the SecurityContext interface, which represents the user's information if logged in, thanks to Jakarta Security. The other point is in the exceptions, as they are all common runtime exceptions, however they are followed by a JAX-RS exception mapper. This approach aims not to “leak” the rules of the controller, which in this case, JAX-RS, is within the business rule. Thus, we launched a business exception that will then be translated into a JAX-RS exception. This approach tends to facilitate testing by avoiding layer leak or layer leakage. This is a very famous good practice, both for DDD, Clean Architecture, and for hexagonal architecture.
xxxxxxxxxx
public class UserForbiddenException extends RuntimeException {
}
public class UserNotAuthorizedException extends RuntimeException {
}
public class UserForbiddenExceptionMapper implements ExceptionMapper<UserForbiddenException> {
public Response toResponse(UserForbiddenException exception) {
return Response.status(Response.Status.FORBIDDEN).build();
}
}
public class UserNotAuthorizedExceptionMapper implements ExceptionMapper<UserNotAuthorizedException> {
public Response toResponse(UserForbiddenException exception) {
return Response.status(Response.Status.UNAUTHORIZED).build();
}
}
We will have SecurityResource in the resource that will be responsible for creating and managing users, and the class will have some operations that only a user profile can perform. In our example, we chose to use the DTO when performing the conversion manually, due to the fact that we have few fields, however, if there are more fields, the use of a mapper is always recommended.
xxxxxxxxxx
import javax.annotation.security.RolesAllowed;
import javax.inject.Inject;
import javax.validation.Valid;
import javax.ws.rs.Consumes;
import javax.ws.rs.DELETE;
import javax.ws.rs.GET;
import javax.ws.rs.POST;
import javax.ws.rs.PUT;
import javax.ws.rs.Path;
import javax.ws.rs.PathParam;
import javax.ws.rs.Produces;
import javax.ws.rs.core.MediaType;
import java.util.List;
"security") (
MediaType.APPLICATION_JSON) (
MediaType.APPLICATION_JSON) (
public class SecurityResource {
private SecurityService service;
public void create( UserDTO userDTO) {
service.create(userDTO);
}
"{id}") (
"ADMIN") (
public void delete( ("id") String id) {
service.delete(id);
}
"{id}") (
public void changePassword( ("id") String id, UserDTO dto) {
service.updatePassword(id, dto);
}
"roles/{id}") (
"ADMIN") (
public void addRole( ("id") String id, RoleDTO dto){
service.addRole(id, dto);
}
"roles/{id}") (
"ADMIN") (
public void removeRole( ("id") String id, RoleDTO dto){
service.removeRole(id, dto);
}
"me") (
public UserDTO getMe() {
return service.getUser();
}
"users") (
"ADMIN") (
public List<UserDTO> getUsers() {
return service.getUsers();
}
}
A feature that saves a lot and validates the input data is certainly Bean Validation. However, it is crucial to ensure that such validation messages are transmitted and not just the error code. For example, the information that the password is mandatory and that it must be at least six characters long. One way to send the error information to the user is to use the JAX-RS exception Mapper again.
xxxxxxxxxx
import javax.validation.ConstraintViolation;
import javax.validation.ConstraintViolationException;
import javax.ws.rs.core.MediaType;
import javax.ws.rs.core.Response;
import javax.ws.rs.ext.ExceptionMapper;
import javax.ws.rs.ext.Provider;
import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;
public class BeanValConstrainViolationExceptionMapper implements ExceptionMapper<ConstraintViolationException> {
public Response toResponse(ConstraintViolationException e) {
Set<ConstraintViolation<?>> cv = e.getConstraintViolations();
final List<String> errors = cv.stream().map(c -> c.getPropertyPath() + " " + c.getMessage()).collect(Collectors.toList());
return Response.status(Response.Status.BAD_REQUEST)
.entity(new ErrorMessage(errors))
.type(MediaType.APPLICATION_JSON)
.build();
}
}
The last step of our code is the creation of IdentityStore, where we will search for the user's information. As mentioned, we make the query based on the user and the hash that was generated from the password.
xxxxxxxxxx
import javax.enterprise.context.ApplicationScoped;
import javax.inject.Inject;
import javax.security.enterprise.credential.Credential;
import javax.security.enterprise.credential.Password;
import javax.security.enterprise.credential.UsernamePasswordCredential;
import javax.security.enterprise.identitystore.CredentialValidationResult;
import javax.security.enterprise.identitystore.IdentityStore;
import javax.security.enterprise.identitystore.Pbkdf2PasswordHash;
import java.util.Optional;
import static javax.security.enterprise.identitystore.CredentialValidationResult.INVALID_RESULT;
public class MongoDBIdentity implements IdentityStore {
private UserRepository repository;
private Pbkdf2PasswordHash passwordHash;
public int priority() {
return 10;
}
public CredentialValidationResult validate(Credential credential) {
if (credential instanceof UsernamePasswordCredential) {
UsernamePasswordCredential userCredential = UsernamePasswordCredential
.class.cast(credential);
final Password userPassword = userCredential.getPassword();
final Optional<User> userOptional = repository.findById(userCredential.getCaller());
if (userOptional.isPresent()) {
final User user = userOptional.get();
if (passwordHash.verify(userPassword.getValue(), user.getPassword())) {
return new CredentialValidationResult(user.getName(), user.getRoles());
}
}
}
return INVALID_RESULT;
}
}
The code is now ready, requiring only local testing. A very easy and intuitive option is certainly Docker.
With that done, we can already perform some tests of our application.
xxxxxxxxxx
curl --location --request POST 'http://localhost:8080/security' --header 'Content-Type: application/json' --data-raw '{"name": "otavio", "password": "otavio"}'
curl --location --request GET 'http://localhost:8080/admin' //returns 401
curl --location --request GET 'http://localhost:8080/admin' --header 'Authorization: Basic b3RhdmlvOm90YXZpbw==' //returns 200
Moving to the Cloud
We already mentioned that we will use a PaaS to facilitate the deployment in the cloud of our application. In addition to the principles, feel free to review the first part of this series. Based on the Hello World example, we will mention the changes. As MongoDB will be used, the service file will be modified to add MongoDB to be managed by the platform.
xxxxxxxxxx
mongodb
type mongodb3.6
disk1024
The application configuration file will have two modifications. The first is related to the relationship that this application will have with the MongoDB database and the other is to overwrite the access information to the bank so that our application is not aware of these credentials. A good practice that we already mentioned, comes from The Twelve Factor App. After making the necessary changes, just push that Platform.sh will create the instance and manage everything, so that we can focus on the business.
xxxxxxxxxx
name app
type"java:11"
disk1024
hooks
build mvn clean package payara-micro bundle
relationships
mongodb'mongodb:mongodb'
web
commands
start
export MONGO_PORT=`echo $PLATFORM_RELATIONSHIPS|base64 -d|json_pp|jq -r ".mongodb[0].port"`
export MONGO_HOST=`echo $PLATFORM_RELATIONSHIPS|base64 -d|json_pp|jq -r ".mongodb[0].host"`
export MONGO_ADDRESS="${MONGO_HOST}:${MONGO_PORT}"
export MONGO_PASSWORD=`echo $PLATFORM_RELATIONSHIPS|base64 -d|json_pp|jq -r ".mongodb[0].password"`
export MONGO_USER=`echo $PLATFORM_RELATIONSHIPS|base64 -d|json_pp|jq -r ".mongodb[0].username"`
export MONGO_DATABASE=`echo $PLATFORM_RELATIONSHIPS|base64 -d|json_pp|jq -r ".mongodb[0].path"`
java -jar -Xmx$(jq .info.limits.memory /run/config.json)m -XX:+ExitOnOutOfMemoryError \
-Ddocument.settings.jakarta.nosql.host=$MONGO_ADDRESS \
-Ddocument.database=$MONGO_DATABASE -Ddocument.settings.jakarta.nosql.user=$MONGO_USER \
-Ddocument.settings.jakarta.nosql.password=$MONGO_PASSWORD \
-Ddocument.settings.mongodb.authentication.source=$MONGO_DATABASE \
target/microprofile-microbundle.jar --port $PORT
With that, we talked a little about BASIC and its advantages and disadvantages using Jakarta Security and MongoDB as a database. We mentioned how encapsulation is also related to security issues and how it impacts the software. See you in the next part of the series where we’ll talk about Auth. You can certainly have access to all the code for the second part here.
Opinions expressed by DZone contributors are their own.
Comments