Despite its popularity, I must admit that when it comes to single-page applications, it’s not simple and straightforward to configure. I suspect the reason is that it started more as an MVC application-oriented framework, where webpage rendering happens on the server-side and communication is session-based.
If the back end is based on Java and Spring, it makes sense to use Spring Security for authentication/authorization and configure it for stateless communication. While there are a lot of articles explaining how this is done, for me, it was still frustrating to set it up for the first time, and I had to read and sum up information from multiple sources. That’s why I decided to write this article, where I will try to summarize and cover all the required subtle details and foibles you may encounter during the configuration process.
Defining Terminology
- Before diving into the technical details, I want to explicitly define the terminology used in the Spring Security context just to be sure that we all speak the same language. These are the terms we need to address:
- Authentication refers to the process of verifying the identity of a user, based on provided credentials. A common example is entering a username and a password when you log in to a website. You can think of it as an answer to the question Who are you?.
- Authorization refers to the process of determining if a user has proper permission to perform a particular action or read particular data, assuming that the user is successfully authenticated. You can think of it as an answer to the question Can a user do/read this?.
- Principle refers to the currently authenticated user.
- Granted authority refers to the permission of the authenticated user.
- Role refers to a group of permissions of the authenticated user.
Creating a Basic Spring Application
Before moving to the configuration of the Spring Security framework, let’s create a basic Spring web application. For this, we can use a Spring Initializr and generate a template project. For a simple web application, only a Spring web framework dependency is enough:<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
</dependencies>
@RestController @RequestMapping("hello")
public class HelloRestController {
@GetMapping("user")
public String helloUser() {
return "Hello User";
}
@GetMapping("admin")
public String helloAdmin() {
return "Hello Admin";
}
}
After this, if we build and run the project, we can access the following URLs in the web browser:http://localhost:8080/hello/user
will return the stringHello User
.http://localhost:8080/hello/admin
will return the stringHello Admin
.
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
</dependencies>
Adding other Spring framework dependencies doesn’t normally have an immediate effect on an application until we provide the corresponding configuration, but Spring Security is different in that it does have an immediate effect, and this usually confuses new users. After adding it, if we rebuild and run the project and then try to access one of the aforementioned URLs instead of viewing the result, we will be redirected to http://localhost:8080/login
. This is default behavior because the Spring Security framework requires authentication out of the box for all URLs.
To pass the authentication, we can use the default username user
and find an auto-generated password in our console :Using generated security password: 1fc15145-dfee-4bec-a009-e32ca21c77ce
Please remember that the password changes each time we rerun the application. If we want to change this behavior and make the password static, we can add the following configuration to our application.properties
file:
spring.security.user.password=Test12345_
Now, if we enter credentials in the login form, we will be redirected back to our URL and we will see the correct result. Please note that the out-of-the-box authentication process is session-based, and if we want to log out, we can access the following URL: http://localhost:8080/logout
As an example, we will implement a classic bookstore web application and create a back end that will provide CRUD APIs to create authors and books plus APIs for user management and authentication.
Spring Security Architecture Overview
Before we start customizing the configuration, let’s first discuss how Spring Security authentication works behind the scenes.The following diagram presents the flow and shows how authentication requests are processed:
Spring Security Architecture
Now, let’s break down this diagram into components and discuss each of them separately.Spring Security Filters Chain
When you add the Spring Security framework to your application, it automatically registers a filters chain that intercepts all incoming requests. This chain consists of various filters, and each of them handles a particular use case.
For example:
- Check if the requested URL is publicly accessible, based on configuration.
- In case of session-based authentication, check if the user is already authenticated in the current session.
- Check if the user is authorized to perform the requested action, and so on.
spring.security.filter.order=10
Once we add this configuration to our application.properties
file, we will have space for 10 custom filters in front of the Spring Security filters.
AuthenticationManager
You can think of AuthenticationManager as a coordinator where you can register multiple providers, and based on the request type, it will deliver an authentication request to the correct provider.AuthenticationProvider
AuthenticationProvider processes specific types of authentication. Its interface exposes only two functions:authenticate
performs authentication with the request.supports
checks if this provider supports the indicated authentication type.
UserDetailsService
UserDetailsService is described as a core interface that loads user-specific data in the Spring documentation.
In most use cases, authentication providers extract user identity information based on credentials from a database and then perform validation. Because this use case is so common, Spring developers decided to extract it as a separate interface, which exposes the single function:
loadUserByUsername
accepts username as a parameter and returns the user identity object.
Authentication Using JWT with Spring Security
After discussing the internals of the Spring Security framework, let’s configure it for stateless authentication with a JWT token.To customize Spring Security, we need a configuration class annotated with @EnableWebSecurity
annotation in our classpath. Also, to simplify the customization process, the framework exposes a WebSecurityConfigurerAdapter
class. We will extend this adapter and override both of its functions so as to:
Configure web security (public URLs, private URLs, authorization, etc.)
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
// TODO configure authentication manager
}
@Override
protected void configure(HttpSecurity http) throws Exception {
// TODO configure web security
}
}
In our sample application, we store user identities in a MongoDB database, in the users collection. These identities are mapped by the User entity, and their CRUD operations are defined by the UserRepo Spring Data repository.
Now, when we accept the authentication request, we need to retrieve the correct identity from the database using the provided credentials and then verify it. For this, we need the implementation of the UserDetailsService interface, which is defined as follows:
public interface UserDetailsService {
UserDetails loadUserByUsername(String username)
throws UsernameNotFoundException;
}
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {
private final UserRepo userRepo;
public SecurityConfig(UserRepo userRepo) {
this.userRepo = userRepo;
}
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.userDetailsService(username -> userRepo
.findByUsername(username)
.orElseThrow(
() -> new UsernameNotFoundException(
format("User: %s, not found", username)
)
));
}
// Details omitted for brevity
}
Here, the auth.userDetailsService function call will initiate the DaoAuthenticationProvider instance using our implementation of the UserDetailsService interface and register it in the authentication manager.
Along with the authentication provider, we need to configure an authentication manager with the correct password-encoding schema that will be used for credentials verification. For this, we need to expose the preferred implementation of the PasswordEncoder interface as a bean.
In our sample project, we will use the bcrypt password-hashing algorithm.
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {
private final UserRepo userRepo;
public SecurityConfig(UserRepo userRepo) {
this.userRepo = userRepo;
}
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.userDetailsService(username -> userRepo
.findByUsername(username)
.orElseThrow(
() -> new UsernameNotFoundException(
format("User: %s, not found", username)
)
));
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
// Details omitted for brevity
}
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {
private final UserRepo userRepo;
private final JwtTokenFilter jwtTokenFilter;
public SecurityConfig(UserRepo userRepo,
JwtTokenFilter jwtTokenFilter) {
this.userRepo = userRepo;
this.jwtTokenFilter = jwtTokenFilter;
}
// Details omitted for brevity
@Override
protected void configure(HttpSecurity http) throws Exception {
// Enable CORS and disable CSRF
http = http.cors().and().csrf().disable();
// Set session management to stateless
http = http
.sessionManagement()
.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.and();
// Set unauthorized requests exception handler
http = http
.exceptionHandling()
.authenticationEntryPoint(
(request, response, ex) -> {
response.sendError(
HttpServletResponse.SC_UNAUTHORIZED,
ex.getMessage()
);
}
)
.and();
// Set permissions on endpoints
http.authorizeRequests()
// Our public endpoints
.antMatchers("/api/public/**").permitAll()
.antMatchers(HttpMethod.GET, "/api/author/**").permitAll()
.antMatchers(HttpMethod.POST, "/api/author/search").permitAll()
.antMatchers(HttpMethod.GET, "/api/book/**").permitAll()
.antMatchers(HttpMethod.POST, "/api/book/search").permitAll()
// Our private endpoints
.anyRequest().authenticated();
// Add JWT token filter
http.addFilterBefore(
jwtTokenFilter,
UsernamePasswordAuthenticationFilter.class
);
}
// Used by spring security if CORS is enabled.
@Bean
public CorsFilter corsFilter() {
UrlBasedCorsConfigurationSource source =
new UrlBasedCorsConfigurationSource();
CorsConfiguration config = new CorsConfiguration();
config.setAllowCredentials(true);
config.addAllowedOrigin("*");
config.addAllowedHeader("*");
config.addAllowedMethod("*");
source.registerCorsConfiguration("/**", config);
return new CorsFilter(source);
}
}
Please note that we added the JwtTokenFilter before the Spring Security internal UsernamePasswordAuthenticationFilter. We’re doing this because we need access to the user identity at this point to perform authentication/authorization, and its extraction happens inside the JWT token filter based on the provided JWT token. This is implemented as follows:@Component
public class JwtTokenFilter extends OncePerRequestFilter {
private final JwtTokenUtil jwtTokenUtil;
private final UserRepo userRepo;
public JwtTokenFilter(JwtTokenUtil jwtTokenUtil,
UserRepo userRepo) {
this.jwtTokenUtil = jwtTokenUtil;
this.userRepo = userRepo;
}
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain chain)
throws ServletException, IOException {
// Get authorization header and validate
final String header = request.getHeader(HttpHeaders.AUTHORIZATION);
if (isEmpty(header) || !header.startsWith("Bearer ")) {
chain.doFilter(request, response);
return;
}
// Get jwt token and validate
final String token = header.split(" ")[1].trim();
if (!jwtTokenUtil.validate(token)) {
chain.doFilter(request, response);
return;
}
// Get user identity and set it on the spring security context
UserDetails userDetails = userRepo
.findByUsername(jwtTokenUtil.getUsername(token))
.orElse(null);
UsernamePasswordAuthenticationToken
authentication = new UsernamePasswordAuthenticationToken(
userDetails, null,
userDetails == null ?
List.of() : userDetails.getAuthorities()
);
authentication.setDetails(
new WebAuthenticationDetailsSource().buildDetails(request)
);
SecurityContextHolder.getContext().setAuthentication(authentication);
chain.doFilter(request, response);
}
}
Before implementing our login API function, we need to take care of one more step - we need access to the authentication manager. By default, it’s not publicly accessible, and we need to explicitly expose it as a bean in our configuration class.
This can be done as follows:
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {
// Details omitted for brevity
@Override @Bean
public AuthenticationManager authenticationManagerBean() throws Exception {
return super.authenticationManagerBean();
}
}
And now, we are ready to implement our login API function:
@Api(tags = "Authentication")
@RestController @RequestMapping(path = "api/public")
public class AuthApi {
private final AuthenticationManager authenticationManager;
private final JwtTokenUtil jwtTokenUtil;
private final UserViewMapper userViewMapper;
public AuthApi(AuthenticationManager authenticationManager,
JwtTokenUtil jwtTokenUtil,
UserViewMapper userViewMapper) {
this.authenticationManager = authenticationManager;
this.jwtTokenUtil = jwtTokenUtil;
this.userViewMapper = userViewMapper;
}
@PostMapping("login")
public ResponseEntity<UserView> login(@RequestBody @Valid AuthRequest request) {
try {
Authentication authenticate = authenticationManager
.authenticate(
new UsernamePasswordAuthenticationToken(
request.getUsername(), request.getPassword()
)
);
User user = (User) authenticate.getPrincipal();
return ResponseEntity.ok()
.header(
HttpHeaders.AUTHORIZATION,
jwtTokenUtil.generateAccessToken(user)
)
.body(userViewMapper.toUserView(user));
} catch (BadCredentialsException ex) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
}
}
}
Here, we verify the provided credentials using the authentication manager, and in case of success, we generate the JWT token and return it as a response header along with the user identity information in the response body.JwtTokenUtil
The JwtTokenUtil is responsible for performing JWT operations like creation and validation. It makes use of the io.jsonwebtoken. Jwts for achieving this.
package com.javainuse.config;import java.io.Serializable;
import java.util.Date;
import java.util.HashMap;
import java.util.Map;
import java.util.function.Function;import org.springframework.beans.factory.annotation.Value;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.stereotype.Component;import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;@Component
public class JwtTokenUtil implements Serializable { private static final long serialVersionUID = -2550185165626007488L; public static final long JWT_TOKEN_VALIDITY = 5 * 60 * 60; @Value("${jwt.secret}")
private String secret; //retrieve username from jwt token
public String getUsernameFromToken(String token) {
return getClaimFromToken(token, Claims::getSubject);
} //retrieve expiration date from jwt token
public Date getExpirationDateFromToken(String token) {
return getClaimFromToken(token, Claims::getExpiration);
} public <T> T getClaimFromToken(String token, Function<Claims, T> claimsResolver) {
final Claims claims = getAllClaimsFromToken(token);
return claimsResolver.apply(claims);
}
//for retrieveing any information from token we will need the secret key
private Claims getAllClaimsFromToken(String token) {
return Jwts.parser().setSigningKey(secret).parseClaimsJws(token).getBody();
} //check if the token has expired
private Boolean isTokenExpired(String token) {
final Date expiration = getExpirationDateFromToken(token);
return expiration.before(new Date());
} //generate token for user
public String generateToken(UserDetails userDetails) {
Map<String, Object> claims = new HashMap<>();
return doGenerateToken(claims, userDetails.getUsername());
} //while creating the token -
//1. Define claims of the token, like Issuer, Expiration, Subject, and the ID
//2. Sign the JWT using the HS512 algorithm and secret key.
//3. According to JWS Compact Serialization(https://tools.ietf.org/html/draft-ietf-jose-json-web-signature-41#section-3.1)
// compaction of the JWT to a URL-safe string
private String doGenerateToken(Map<String, Object> claims, String subject) { return Jwts.builder().setClaims(claims).setSubject(subject).setIssuedAt(new Date(System.currentTimeMillis()))
.setExpiration(new Date(System.currentTimeMillis() + JWT_TOKEN_VALIDITY * 1000))
.signWith(SignatureAlgorithm.HS512, secret).compact();
} //validate token
public Boolean validateToken(String token, UserDetails userDetails) {
final String username = getUsernameFromToken(token);
return (username.equals(userDetails.getUsername()) && !isTokenExpired(token));
}
}
Authorization with Spring Security
In the previous section, we set up an authentication process and configured public/private URLs. This may be enough for simple applications, but for most real-world use cases, we always need role-based access policies for our users. In this chapter, we will address this issue and set up a role-based authorization schema using the Spring Security framework.In our sample application, we have defined the following three roles:
USER_ADMIN
allows us to manage application users.AUTHOR_ADMIN
allows us to manage authors.BOOK_ADMIN
allows us to manage books.
api/public
is publicly accessible.api/admin/user
can access users with theUSER_ADMIN
role.api/author
can access users with theAUTHOR_ADMIN
role.api/book
can access users with theBOOK_ADMIN
role.
- URL-based configuration
- Annotation-based configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {
// Details omitted for brevity
@Override
protected void configure(HttpSecurity http) throws Exception {
// Enable CORS and disable CSRF
http = http.cors().and().csrf().disable();
// Set session management to stateless
http = http
.sessionManagement()
.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.and();
// Set unauthorized requests exception handler
http = http
.exceptionHandling()
.authenticationEntryPoint(
(request, response, ex) -> {
response.sendError(
HttpServletResponse.SC_UNAUTHORIZED,
ex.getMessage()
);
}
)
.and();
// Set permissions on endpoints
http.authorizeRequests()
// Our public endpoints
.antMatchers("/api/public/**").permitAll()
.antMatchers(HttpMethod.GET, "/api/author/**").permitAll()
.antMatchers(HttpMethod.POST, "/api/author/search").permitAll()
.antMatchers(HttpMethod.GET, "/api/book/**").permitAll()
.antMatchers(HttpMethod.POST, "/api/book/search").permitAll()
// Our private endpoints
.antMatchers("/api/admin/user/**").hasRole(Role.USER_ADMIN)
.antMatchers("/api/author/**").hasRole(Role.AUTHOR_ADMIN)
.antMatchers("/api/book/**").hasRole(Role.BOOK_ADMIN)
.anyRequest().authenticated();
// Add JWT token filter
http.addFilterBefore(
jwtTokenFilter,
UsernamePasswordAuthenticationFilter.class
);
}
// Details omitted for brevity
}
As you can see, this approach is simple and straightforward, but it has one downside. The authorization schema in our application can be complex, and if we define all the rules in a single place, it will become very big, complex, and hard to read. Because of this, I usually prefer to use annotation-based configuration.
The Spring Security framework defines the following annotations for web security:
@PreAuthorize
supports Spring Expression Language and is used to provide expression-based access control before executing the method.@PostAuthorize
supports Spring Expression Language and is used to provide expression-based access control after executing the method (provides the ability to access the method result).@PreFilter
supports Spring Expression Language and is used to filter the collection or arrays before executing the method based on custom security rules we define.@PostFilter
supports Spring Expression Language and is used to filter the returned collection or arrays after executing the method based on custom security rules we define (provides the ability to access the method result).@Secured
doesn’t support Spring Expression Language and is used to specify a list of roles on a method.@RolesAllowed
doesn’t support Spring Expression Language and is the JSR 250’s equivalent annotation of the@Secured
annotation.
@EnableWebSecurity
@EnableGlobalMethodSecurity(
securedEnabled = true,
jsr250Enabled = true,
prePostEnabled = true
)
public class SecurityConfig extends WebSecurityConfigurerAdapter {
// Details omitted for brevity
}
securedEnabled = true
enables @Secured
annotation.jsr250Enabled = true
enables @RolesAllowed
annotation.prePostEnabled = true
enables @PreAuthorize
, @PostAuthorize
, @PreFilter
, @PostFilter
annotations.
After enabling them, we can enforce role-based access policies on our API endpoints like this:
@Api(tags = "UserAdmin")
@RestController @RequestMapping(path = "api/admin/user")
@RolesAllowed(Role.USER_ADMIN)
public class UserAdminApi {
// Details omitted for brevity
}
@Api(tags = "Author")
@RestController @RequestMapping(path = "api/author")
public class AuthorApi {
// Details omitted for brevity
@RolesAllowed(Role.AUTHOR_ADMIN)
@PostMapping
public void create() { }
@RolesAllowed(Role.AUTHOR_ADMIN)
@PutMapping("{id}")
public void edit() { }
@RolesAllowed(Role.AUTHOR_ADMIN)
@DeleteMapping("{id}")
public void delete() { }
@GetMapping("{id}")
public void get() { }
@GetMapping("{id}/book")
public void getBooks() { }
@PostMapping("search")
public void search() { }
}
@Api(tags = "Book")
@RestController @RequestMapping(path = "api/book")
public class BookApi {
// Details omitted for brevity
@RolesAllowed(Role.BOOK_ADMIN)
@PostMapping
public BookView create() { }
@RolesAllowed(Role.BOOK_ADMIN)
@PutMapping("{id}")
public void edit() { }
@RolesAllowed(Role.BOOK_ADMIN)
@DeleteMapping("{id}")
public void delete() { }
@GetMapping("{id}")
public void get() { }
@GetMapping("{id}/author")
public void getAuthors() { }
@PostMapping("search")
public void search() { }
}
Please note that security annotations can be provided both on the class level and the method level.
The demonstrated examples are simple and do not represent real-world scenarios, but Spring Security provides a rich set of annotations, and you can handle a complex authorization schema if you choose to use them.
Role Name Default Prefix
In this separate subsection, I want to emphasize one more subtle detail that confuses a lot of new users.The Spring Security framework differentiates two terms:
Authority
represents an individual permission.Role
represents a group of permissions.
Authority
: @PreAuthorize(“hasAuthority(‘EDIT_BOOK’)”)Role
: @PreAuthorize(“hasRole(‘BOOK_ADMIN’)”)
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {
// Details omitted for brevity
@Bean
GrantedAuthorityDefaults grantedAuthorityDefaults() {
return new GrantedAuthorityDefaults(""); // Remove the ROLE_ prefix
}
}
Testing with Spring Security
To test our endpoints with unit or integration tests when using the Spring Security framework, we need to add spring-security-test dependency along with the spring-boot-starter-test. Our pom.xml build file will look like this:<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
<exclusions>
<exclusion>
<groupId>org.junit.vintage</groupId>
<artifactId>junit-vintage-engine</artifactId>
</exclusion>
</exclusions>
</dependency>
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-test</artifactId>
<scope>test</scope>
</dependency>
This dependency gives us access to some annotations that can be used to add security context to our test functions.
These annotations are:
@WithMockUser
can be added to a test method to emulate running with a mocked user.@WithUserDetails
can be added to a test method to emulate running withUserDetails
returned from theUserDetailsService
.@WithAnonymousUser
can be added to a test method to emulate running with an anonymous user. This is useful when a user wants to run a majority of tests as a specific user and override a few methods to be anonymous.@WithSecurityContext
determines whatSecurityContext
to use, and all three annotations described above are based on it. If we have a specific use case, we can create our own annotation that uses@WithSecurityContext
to create anySecurityContext
we want. Its discussion is outside the scope of our article, and please refer to the Spring Security documentation for further details.
@Test @WithMockUser(username="customUsername@example.io", roles={"USER_ADMIN"})
public void test() {
// Details omitted for brevity
}
This approach has a couple of drawbacks, though. First, the mock user doesn’t exist, and if you run the integration test, which later queries the user information from the database, the test will fail. Second, the mock user is the instance of the org.springframework.security.core.userdetails.User class, which is the Spring framework’s internal implementation of the UserDetails interface, and if we have our own implementation, this can cause conflicts later, during test execution.
If previous drawbacks are blockers for our application, then the @WithUserDetails annotation is the way to go. It is used when we have custom UserDetails and UserDetailsService implementations.
@Test @WithUserDetails("customUsername@example.io")
public void test() {
// Details omitted for brevity
}
This is a preferred annotation in our sample project’s integration tests because we have custom implementations of the aforementioned interfaces.@SpringBootTest
@AutoConfigureMockMvc
@WithMockUser
public class WithUserClassLevelAuthenticationTests {
@Test
public void test1() {
// Details omitted for brevity
}
@Test
public void test2() {
// Details omitted for brevity
}
@Test @WithAnonymousUser
public void test3() throws Exception {
// Details omitted for brevity
}
}
Comments
Post a Comment