Spring Boot - Security in distributed system
Spring Security is indispensable part of every enterprise Spring based application. Everything looks pretty easy, when you want to secure single web-application. Unfortunately, everything looks completely different when your system consists of many components. In that cases the expected behavior is single login for whole system, not separate login for each web-services. Suppose, you have two RESTful web-services. Application should allow a user to login, receive session token and give access to resources of both services using the token. At this point we come across the first problem. Spring does not support authentication by JSON object out of the box. In order to send credentials as json object, we have to write some handlers. But let’s start from the beginning.
*All sources for this project you can find on my github repository:
https://github.com/lstypka/spring-security-distributed-system
|-pom.xml
|--\client1
|--\client2
|--\security-config
The client1 and the client2 will be RESTful web-services with single endpoint. The security-config will be heart of this article - this project will include whole security stuff, some JSON handlers and filters as well.
Parent pom.xml
...
<properties>
<java.version>1.8</java.version>
<spring.boot.version>1.3.0.RELEASE</spring.boot.version>
<spring.session.version>1.0.2.RELEASE</spring.session.version>
</properties>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-dependencies</artifactId>
<version>${spring.boot.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
<version>${spring.boot.version}</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
<version>${spring.boot.version}</version>
</dependency>
<dependency>
<groupId>org.springframework.session</groupId>
<artifactId>spring-session-data-redis</artifactId>
<version>${spring.session.version}</version>
<type>pom</type>
</dependency>
</dependencies>
</dependencyManagement>
...
client1 and client2 pom.xml
...
<dependencies>
<dependency>
<groupId>pl.lstypka.spring-security-distributed-system</groupId>
<artifactId>security-config</artifactId>
<version>1.0-SNAPSHOT</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
...
security-config pom.xml
...
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.session</groupId>
<artifactId>spring-session-data-redis</artifactId>
<type>pom</type>
</dependency>
</dependencies>
...
Once we have the basic projects configuration, we can proceed to something more interesting. The first thing will be configuring spring security in our project.
For this purpose we’ll create SecurityConfig
class which extends WebSecurityConfigurerAdapter
.
public class SecurityConfig extends WebSecurityConfigurerAdapter {
private final static String AUTHENTICATE_ENDPOINT = "/authenticate";
// Beans connected with translating input and output to JSON
@Bean
AuthenticationFailureHandler authenticationFailureHandler() {
return new AuthenticationFailureHandler();
}
@Bean
AuthenticationSuccessHandler authenticationSuccessHandler() {
return new AuthenticationSuccessHandler();
}
@Bean
RestAuthenticationEntryPoint restAuthenticationEntryPoint() {
return new RestAuthenticationEntryPoint();
}
// Bean responsible for getting information about user details
@Bean
AuthService authService() {
return new AuthService();
}
@Bean
public CustomUsernamePasswordAuthenticationFilter authenticationFilter() throws Exception {
CustomUsernamePasswordAuthenticationFilter authFilter = new CustomUsernamePasswordAuthenticationFilter();
authFilter.setRequiresAuthenticationRequestMatcher(new AntPathRequestMatcher(AUTHENTICATE_ENDPOINT, "POST"));
authFilter.setAuthenticationManager(super.authenticationManager());
authFilter.setAuthenticationSuccessHandler(authenticationSuccessHandler());
authFilter.setAuthenticationFailureHandler(authenticationFailureHandler());
authFilter.setUsernameParameter("j_username");
authFilter.setPasswordParameter("j_password");
return authFilter;
}
@Override
protected void configure(HttpSecurity http) throws Exception {
http.exceptionHandling().authenticationEntryPoint(restAuthenticationEntryPoint())
.and().addFilterBefore(authenticationFilter(), CustomUsernamePasswordAuthenticationFilter.class)
.csrf().disable().authorizeRequests().antMatchers("/**").authenticated().and().formLogin()
.loginProcessingUrl(AUTHENTICATE_ENDPOINT).failureHandler(authenticationFailureHandler())
.successHandler(authenticationSuccessHandler()).and().logout();
}
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.userDetailsService(authService());
}
}
As you can see, there are two overridden configure methods. Inside the first one I have added some filters, handlers, and I have indicated that all request should be protected (authorizeRequests().antMatchers(“/**”).authenticated()). I’ve also configured login endpoint as (“/authenticate”). We will back in a moment to discuss the AuthenticationFailureHandler
, AuthenticationSuccessHandler
and CustomUsernamePasswordAuthenticationFilter
. First, I would like to take a minute to highlight the AuthService
class. It’s simple service class which implements org.springframework.security.core.userdetails.UserDetailsService
interface. The class is responsible for provide information about user. Example implementation:
@Service("authService")
public class AuthService implements UserDetailsService {
@Override
public SecurityUser loadUserByUsername(String username) {
List<GrantedAuthority> authorities = new ArrayList<>();
if("admin".equals(username)) {
authorities.add(()-> "ROLE_ADMIN");
return new SecurityUser(1L, username, "s3cr3t", authorities);
}
if("user".equals(username)) {
authorities.add(()-> "ROLE_USER");
return new SecurityUser(2L, username, "s3cr3t", authorities);
}
throw new UserNotFoundException("User %s not found".format(username));
};
public UserDto getLoggedUser() {
SecurityUser securityUser = (SecurityUser) SecurityContextHolder.getContext().getAuthentication().getPrincipal();
return new UserDto(securityUser.getUserNo(), securityUser.getUsername(), securityUser.getAuthorities().stream().map(x -> x.getAuthority()).collect(Collectors.toList()));
};
}
As you can see, current implementation allows to login users: ‘user’ and ‘admin’. This implementation makes sense only for tests, and production implementation should invoke some repositories to get real user details.
The key thing that is needed to send credentials as JSON object is writing custom filter which extends UsernamePasswordAuthenticationFilter
.
public class CustomUsernamePasswordAuthenticationFilter extends UsernamePasswordAuthenticationFilter {
private final static String USERNAME = "user";
private final static String PASSWORD = "password";
private final static String CONTENT_TYPE = "Content-Type";
@Override
protected String obtainPassword(HttpServletRequest request) {
if (request.getHeader(CONTENT_TYPE).contains("application/json")) {
return (String) request.getAttribute(PASSWORD);
} else {
return super.obtainPassword(request);
}
}
@Override
protected String obtainUsername(HttpServletRequest request) {
if (request.getHeader(CONTENT_TYPE).contains("application/json")) {
return (String) request.getAttribute(USERNAME);
} else {
return super.obtainUsername(request);
}
}
@Override
public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) {
if (request.getHeader(CONTENT_TYPE).contains("application/json")) {
try {
/*
* HttpServletRequest can be read only once
*/
StringBuffer sb = new StringBuffer();
String line = null;
BufferedReader reader = request.getReader();
while ((line = reader.readLine()) != null) {
sb.append(line);
}
// json transformation
ObjectMapper mapper = new ObjectMapper();
LoginRequestDto loginRequest = mapper.readValue(sb.toString(), LoginRequestDto.class);
// persist user and password as request attribute
request.setAttribute(USERNAME, loginRequest.getUser());
request.setAttribute(PASSWORD, loginRequest.getPassword());
} catch (IOException e) {
throw new RuntimeException(e.getMessage());
}
}
return super.attemptAuthentication(request, response);
}
}
This filter provides information about username and password for security spring. But, what about situation when credentials are incorrect.
RESTful service should return appropriate Http code and error as a JSON object. To do this, we have to create AuthenticationSuccessHandler
:
public class AuthenticationFailureHandler extends SimpleUrlAuthenticationFailureHandler {
@Override
public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) throws IOException, ServletException {
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
PrintWriter writer = response.getWriter();
ObjectMapper mapper = new ObjectMapper();
FaultDto faultDto = new FaultDto("SPRING-SECURITY-1", exception.getMessage());
writer.println(mapper.writeValueAsString(faultDto));
}
}
In the case of valid credentials, we should return response as a JSON object too. In the following example, I return object consists of userNo, username and list of roles.
public class AuthenticationSuccessHandler extends SimpleUrlAuthenticationSuccessHandler {
@Override
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws ServletException, IOException {
SecurityUser userSecurity = (SecurityUser) authentication.getPrincipal();
ObjectMapper mapper = new ObjectMapper();
PrintWriter writer = response.getWriter();
UserDto userDto = new UserDto(userSecurity.getUserNo(), userSecurity.getUsername(), userSecurity.getAuthorities().stream().map(x -> x.getAuthority()).collect(Collectors.toList()));
mapper.writeValue(writer, userDto);
writer.flush();
}
}
At this moment, we have a fully working configuration of Spring Security with JSON credentials. Now, you can add endpoint and configuration to client1 application:
Example endpoint:
@RestController
public class HomeController {
@RequestMapping("/time")
public String getTime() {
return LocalDateTime.now().format(DateTimeFormatter.ISO_LOCAL_DATE_TIME);
}
}
SpringBoot application:
@SpringBootApplication
@ComponentScan({"pl.lstypka.springSecurityDistributedSystem.client1"})
@Import({SecurityConfig.class})
public class Application {
public static void main(String[] args) throws Throwable {
SpringApplication.run(Application.class, args);
}
}
You can run main method from your IDE or execute the following command in your terminal:
mvn spring-boot:run -Dserver.port=8099
If you try to invoke the endpoint http://localhost:8099/time , you will receive the following error:
{
"errorCode" : "SPRING-SECURITY-1",
"message" : "Full authentication is required to access this resource"
}
It shouldn’t be a surprise, because we have forgotten about authorisation. We have defined authentication endpoint as ‘\authenticate’, so let’s send credentials there
curl -X POST http://localhost:8099/authenticate
-H "Content-Type: application/json" -d "{\"user\" : \"admin\", \"password\" : \"s3cr3t\"}" -v
Result is easy to predict:
* Connected to localhost (::1) port 8099 (#0)
> POST /authenticate HTTP/1.1
> User-Agent: curl/7.30.0
> Host: localhost:8099
> Accept: */*
> Content-Type: application/json
> Content-Length: 41
>
* upload completely sent off: 41 out of 41 bytes
< HTTP/1.1 200 OK
* Server Apache-Coyote/1.1 is not blacklisted
< Server: Apache-Coyote/1.1
< X-Content-Type-Options: nosniff
< X-XSS-Protection: 1; mode=block
< Cache-Control: no-cache, no-store, max-age=0, must-revalidate
< Pragma: no-cache
< Expires: 0
< X-Frame-Options: DENY
< Set-Cookie: SESSION=449b0dce-cad9-4aa5-9b27-8896b20265ae; Path=/; HttpOnly
< Content-Length: 54
< Date: Sat, 21 Nov 2015 20:12:44 GMT
<
{"userNo":1,"username":"admin","roles":["ROLE_ADMIN"]}
Once we have a session token, we can try invoke curl http://localhost:8099/time --cookie "SESSION=449b0dce-cad9-4aa5-9b27-8896b20265ae"
once again. This time with success. Unfortunately, when you try to invoke similar endpoint for client2, you will receive “Full authentication is required to access this resource” error. This is understandable, because the security context is not shared. So what should we do to have such an opportunity? We should use redis as database of session tokens. First of all, you need to install redis on your computer. If you use Windows, you will find appropriate installer here: https://github.com/MSOpenTech/redis/releases
Installers for other systems are available here: http://redis.io/download
To use Redis in our application we need to add dependency to security-config pom.xml:
<dependency>
<groupId>org.springframework.session</groupId>
<artifactId>spring-session-data-redis</artifactId>
<type>pom</type>
</dependency>
Spring configuration is quite simple: one annotation @EnableRedisHttpSession
above SecurityConfig
class, and two beans within this class:
@EnableRedisHttpSession
public class SecurityConfig extends WebSecurityConfigurerAdapter {
...
// These beans are required to sharing session tokens (in the redis database) between applications
@Bean
public JedisConnectionFactory connectionFactory() {
return new JedisConnectionFactory();
}
@Bean
public HttpSessionStrategy httpSessionStrategy() {
return new HeaderHttpSessionStrategy();
}
...
}
Let’s try invoke \authenticate
endpoint once again. Response is similar to previous one, but there is one additional x-auth-token
header.
If you want to invoke http://localhost:8099/time
, you have to remember resend x-auth-token
. You can also invoke any endpoint on the client2 by using the token generated within the client1. Everything will work correctly.
All sources for this project you can find on my github repository:
https://github.com/lstypka/spring-security-distributed-system