REST API with Spring Boot 3 — Part 4

Tiago Albuquerque
8 min readOct 11, 2023

--

API Authentication and Authorization with Spring Security

This is the fourth post of my REST API series, where I write about my impressions and experience with this architecture style using Java with Spring Boot.

The full series of articles are listed below:

The implementation of each article is available on GitHub in a specific branch, and the final implementation is on the main branch.

In this article we will secure our endpoints using Spring Security for authentication and authorized access.

The code of this article is available on GitHub in branch ‘ch-04’.

Spring Security

Spring Security is a framework that focuses on providing both authentication and authorization to Java applications. It is the standard for securing Spring based applications.

To enable its features, first of all we need to add its dependency in pom.xml, as well the java-jwt dependency to use JWT tokens in this example:

<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>com.auth0</groupId>
<artifactId>java-jwt</artifactId>
<version>4.4.0</version>
</dependency>

In my opinion, its a little confusing to initially understand its configuration classes and beans, but in Spring Boot 3.0 its more simple and once you get it working it is easy to maintain.

New Domain Classes

We will need to create a class to represent the application user, as well a enumeration to represent its roles. In this example, we’ll assume that the ‘e -mail’ information is used as the login key for the authentication.

We will make the AppUser class to implements the ‘UserDetails’ interface, that will make this class to be recognizable as the class that represents an user to the Spring Security framework.

Notice the ‘getAuthorities()’, which returns the currente roles of the user, should add the “ROLE_” prefix to the role name, because it is used this way when it’s been validated after.

@Data
@NoArgsConstructor
@AllArgsConstructor
@Entity
@Table(name = "USERS")
public class AppUser implements UserDetails {

@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Integer id;

private String name;

private String email;

private String password;

@Enumerated(EnumType.STRING)
private Role role;


public AppUser(String name, String email, String password, Role role) {
this.name = name;
this.email = email;
this.password = password;
this.role = role;
}


@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
// it uses de preffix 'ROLE_' to validate at controller methods
return List.of(new SimpleGrantedAuthority("ROLE_" + role.name()));
}

@Override
public String getUsername() {
return this.email;
}

@Override
public boolean isAccountNonExpired() {
return true;
}

@Override
public boolean isAccountNonLocked() {
return true;
}

@Override
public boolean isCredentialsNonExpired() {
return true;
}

@Override
public boolean isEnabled() {
return true;
}

}


public enum Role {
USER,
ADMIN;
}

New Controller, Services and Repositories

To handle operations for application Users, we will need to create classes in each layer to persist and provide data about Users and Roles.

We can go by a bottom-up approach, by first implementing the repository classes (using Spring Data interfaces):

@Repository
public interface UserRepository extends JpaRepository<AppUser, Integer> {
Optional<AppUser> findByEmail(String email);
boolean existsByEmail(String email);
}

Notice that in the repository we will need to provide a ‘findByEmail’ method, which will be used by authentication/authorization lifecycle.

We cannot persist the plain user password, so we are going to need a password encoder. The current most used encryption algorithm is BCrypt, and Spring provides us a implementation of it. So, we need to register its instance as a Spring Bean, and I choose to do that in the application main class:

@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}

Let’s create an UserService class, which will need to implement the interface UserDetailsService to override its ‘loadUserByUsername’ method. Here we will inject the repositories and password encoder to provide users operations:

@Service
@RequiredArgsConstructor
public class UserService implements UserDetailsService {

private final UserRepository userRepo;
private final PasswordEncoder passwordEncoder;

public AppUser save(AppUser user) {
if (userRepo.existsByEmail(user.getEmail()))
throw new ResourceAlreadyExistsException("User", user.getEmail());
user.setPassword(passwordEncoder.encode(user.getPassword()));
return userRepo.save(user);
}

public List<AppUser> find() {
return userRepo.findAll();
}

@Override
public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException {
return userRepo.findByEmail(email)
.orElseThrow(() -> new UsernameNotFoundException("User not found"));
}

}

And finally, we can expose endpoints to handle Users operations through the API (fetch and create), creating the UserController class:

@RestController
@RequestMapping("/users")
@RequiredArgsConstructor
public class UserController {

private final UserService userService;
private final UserMapper mapper;

@GetMapping
public ResponseEntity<List<AppUserResponse>> getUsers() {
List<AppUser> users = userService.find();
var resp = users.stream().map(mapper::toResponse).toList();
return ResponseEntity.ok(resp);
}

@PostMapping
public ResponseEntity<AppUserResponse> createUser(@RequestBody @Valid AppUserRequest request) {
var user = mapper.toModel(request);
user = userService.save(user);
var resp = mapper.toResponse(user);
return ResponseEntity.created(URI.create(user.getId().toString())).body(resp);
}

}

As the user’s password needs to be encrypted, it will not be possible to add a SQL script to add user data in the application initialization. But we can create a CommandLineRunner Bean that do the job.

Here is the code that I added in the application main class to create inital users data in our database:

@Bean
CommandLineRunner run(UserService userService) {
return args -> { // inserting data after application is up
userService.save(new AppUser("James Kirk", "james@enterprise.com", "123456", Role.ADMIN));
userService.save(new AppUser("Spock", "spock@enterprise.com", "123456", Role.ADMIN));
userService.save(new AppUser("Leonard McCoy", "mccoy@enterprise.com", "123456", Role.USER));
userService.save(new AppUser("Montgomery Scott", "scott@enterprise.com", "123456", Role.USER));
};
}

Security configuration

After authenticate the user by its password, we will use JSON Web Tokens (JWT) for further requests to identify and authorize its access to the operations endpoints.

I created a class ‘TokenService’ to aggregate reusable methods of token generation and verification, and to inject the secret string (stored in application.yml).

The requests received should have the ‘Authorization’ header set starting with the “Bearer ” word followed by the JWT token to be decoded.

...
secret:
key: ${JWT_SECRET:1234567890qwertyuiop} # example of environment variable or default value if not found

TokenService methods implementation:

@Service
@RequiredArgsConstructor
public class TokenService {

@Value("${secret.key}")
private String secret;

public String getTokenFrom(String bearerToken) {
final String bearer = "Bearer ";
if (bearerToken == null || !bearerToken.startsWith(bearer))
throw new JWTVerificationException("Invalid Authorization Header");
String token = bearerToken.substring(bearer.length());
return token;
}

public String getSubjectFrom(String token) {
Algorithm algorithm = Algorithm.HMAC256(secret.getBytes());
JWTVerifier verifier = JWT.require(algorithm).build();
DecodedJWT decodedJWT = verifier.verify(token); // throws JWTVerificationException if not valid
return decodedJWT.getSubject();
}

public String generateToken(AppUser user) {
Algorithm algorithm = Algorithm.HMAC256(secret.getBytes());
Instant expiration = generateExpirationTimeIn(10); // expires in 10 min
String token = JWT.create()
.withSubject(user.getUsername())
.withExpiresAt(expiration)
.withIssuer("Books-API")
.withClaim("roles", user.getRole().name())
.sign(algorithm);
return token;
}

private Instant generateExpirationTimeIn(int minutes) {
return LocalDateTime.now().plusMinutes(minutes).atZone(ZoneId.systemDefault()).toInstant();
}
}

We will provide an endpoint to authenticate the user (by its registered password) and return the JWT token (access token) for further requests (as long as the current token is not expired).

Let’s add the authenticate operation in UserController:

@PostMapping("/login")
public ResponseEntity<AuthenticationResponse> login(@RequestBody @Valid AuthenticationRequest request) {
var token = authenticationService.authenticate(request.getEmail(), request.getPassword());
return ResponseEntity.ok(new AuthenticationResponse(token));
}

And the authentication logic is provided in a new service class, that we named AuthenticationService:

@Service
@RequiredArgsConstructor
public class AuthenticationService {

private final AuthenticationManager authenticationManager;
private final TokenService tokenService;

public String authenticate(String email, String password) {
try {
// The authentication manager provides secure authentication and throws exception if it fails
var authToken = new UsernamePasswordAuthenticationToken(email, password);
Authentication authenticate = authenticationManager.authenticate(authToken);
var user = (AppUser) authenticate.getPrincipal();
String token = tokenService.generateToken(user);
return token;
} catch (AuthenticationException e) {
throw new AuthenticationFailedException("Invalid User or Password");
}
}

}

And now we can create another filter to handle authorization process. It will intercept the requests for protected operations, load its JWT token and validate it. If its a valid token the filter let the request go on, otherwise it will throw an authorization error.

@Component
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {

private final TokenService tokenService;
private final UserDetailsService userService;

@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
String authorizationHeader = request.getHeader(HttpHeaders.AUTHORIZATION);
if (authorizationHeader == null || !authorizationHeader.startsWith("Bearer ")) {
filterChain.doFilter(request, response);
return;
}
try {
String token = tokenService.getTokenFrom(authorizationHeader);
String userEmail = tokenService.getSubjectFrom(token);
UserDetails user = userService.loadUserByUsername(userEmail);
var authenticationToken = new UsernamePasswordAuthenticationToken(userEmail, null, user.getAuthorities());
authenticationToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
SecurityContextHolder.getContext().setAuthentication(authenticationToken);
filterChain.doFilter(request, response);
} catch (JWTVerificationException ex) {
response.setHeader("error", ex.getMessage());
response.setStatus(HttpStatus.FORBIDDEN.value());
Map<String, String> error = new HashMap<>();
error.put("error", ex.getMessage());
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
new ObjectMapper().writeValue(response.getOutputStream(), error);
}

}

}

And finally we need to create a security configuration class. We will need to enable web security, add some annotations and create some Beans to configure the protected and unauthenticated endpoints.

@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
// Annotation below to allow roles validated by methods annotations
@EnableGlobalMethodSecurity(prePostEnabled = true, securedEnabled = true, jsr250Enabled = true)
public class SecurityConfiguration {

private final JwtAuthenticationFilter jwtAuthFilter;
private final UserDetailsService userService;
private final PasswordEncoder passwordEncoder;

@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http.csrf(csrf -> csrf.disable())
.sessionManagement(sess -> sess.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) // Do not create session
.authorizeHttpRequests(auth -> auth
.requestMatchers("/", "/swagger-ui/**", "/api-docs", "/api-docs/**").permitAll() // white list (swagger and root entry point)
.requestMatchers(HttpMethod.GET, "/users").permitAll() // white list: login and fetch users list
.requestMatchers(HttpMethod.POST, "/users/login").permitAll()
.anyRequest().authenticated()
)
.authenticationProvider(authenticationProvider())
.addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class);
return http.build();
}

@Bean
public AuthenticationProvider authenticationProvider() {
DaoAuthenticationProvider authenticationProvider = new DaoAuthenticationProvider();
authenticationProvider.setUserDetailsService(userService);
authenticationProvider.setPasswordEncoder(passwordEncoder);
return authenticationProvider;
}

@Bean
public AuthenticationManager authenticationManager(AuthenticationConfiguration config) throws Exception {
return config.getAuthenticationManager();
}

}

Notice that in the SecurityFilterChain bean we added the Swagger resources and login endpoint to be accessible by anyone, with no need for previous authentication, using the ‘permitAll()’ method.

We can provide authorization based on Roles using the kind of pattern matchers shown below:

...
.requestMatchers(HttpMethod.POST, "/books").hasRole("ADMIN")
...

But I prefer to use some annotations direct in the controllers methods. To do so, first we need to enable this kind of feature by using the ‘EnableGlobalMethodSecurity’ annotation:

// to allow roles validated by methods annotations
@EnableGlobalMethodSecurity(
prePostEnabled = true,
securedEnabled = true,
jsr250Enabled = true)
public class SecurityConfiguration { ... }

Now we can use the @RolesAllowed annotation indicating the roles that are allowed to execute the decorated method.

Some examples in BookController class:

@RolesAllowed( {"ADMIN","USER"} ) // needs to enable 'EnableGlobalMethodSecurity' at security class to work
public ResponseEntity<List<BookResponse>> getBooks() { ... }

@RolesAllowed( {"ADMIN","USER"} )
public ResponseEntity<BookResponse> getBook(@PathVariable("id") Integer id)
{ ... }

@RolesAllowed("ADMIN")
public ResponseEntity<BookResponse> createBook(@RequestBody @Valid BookRequest request)
{ ... }

@RolesAllowed("ADMIN")
public ResponseEntity<BookResponse> updateBook(@PathVariable("id") Integer id, @RequestBody @Valid BookRequest request)
{ ... }

@RolesAllowed("ADMIN") // needs to enable 'EnableGlobalMethodSecurity' at security class to work
public ResponseEntity deleteBook(@PathVariable("id") Integer id) { ... }

@RolesAllowed( {"ADMIN","USER"} )
public ResponseEntity<List<ReviewResponse>> getReviews(@PathVariable("bookId") Integer bookId)
{ ... }

@RolesAllowed( {"ADMIN","USER"} )
public ResponseEntity<ReviewResponse> createReview(@PathVariable("bookId") Integer bookId, @RequestBody @Valid ReviewRequest request)
{ ... }

Testing the application

With all set up, we can run the application to test the operations.

If we try to access the Books resources with no authentication token, we’ll get an access error:

Unauthenticated access error

We need to get the JWT token after log in with a registered credential, using the unauthenticated login endpoint “/api/v1/users/login”:

Login request with token response

Now we can use the JWT token received to do the same request to get Books resources, but setting the token up int the ‘Authorization’ header:

Authenticated request with successful response

And then we can get the proper response, as we can see above.

When the request is made by a user with no role allowed to perform the operation, we should have an ‘HTTP 403 — Forbidden’ error as well:

User with unauthorized role to perform the operation

Resources and further reading:

--

--