Microservices, Eureka and Zuul Gateway User Manual - Part 2
In the previous article, we successfully built a simple microservice system consisting of the User service, Eureka server, and Zuul gateway.
In this article, we will delve into understanding JWT and the approach to authentication and security within the Microservice architecture using JWT.
We will create a new service called the auth service
, which will be responsible for verifying identities and generating JWT tokens upon user login. The task of token authentication will be handled by the Zuul gateway. When a request is received, the Zuul gateway will verify the provided token, authenticate access rights. Only upon successful authentication will the Zuul gateway proceed to route the request to other business services.
1. JWT (Json Based Token)
Token is a string of characters that is generated by our system after a successful authentication and is attached to requests to grant access to the application.
JWT (JSON Web Token) is a standard for creating tokens, consisting of three parts:
- Header: Contains the hash algorithm.
{type: "JWT", hash: "HS256"}
- Payload: Contains attributes like username, email, and their values.
{username: "Omar", email: "omar@example.com", admin: true}
- Signature: This is the hash value of
Header + "." + Payload + Secret key
.
2. Zuul gateway
In the gateway, two functions will be performed: firstly, token authentication, and secondly, blocking all requests if authentication fails.
In the pom.xml
file, we need to add Spring Security and JWT dependencies.
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt</artifactId>
</dependency>
To use security, we need to create a class that extends WebSecurityConfigurerAdapter
and use the @EnableWebSecurity
annotation.
package com.example.zuulserver.security;
import com.example.commonservice.security.JwtConfig;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import javax.servlet.http.HttpServletResponse;
import javax.ws.rs.HttpMethod;
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Autowired
private JwtConfig jwtConfig;
@Override
protected void configure(HttpSecurity http) throws Exception {
http
.csrf().disable()
// make sure we use stateless session; session won't be used to store user's state.
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.and()
// handle an authorized attempts
.exceptionHandling().authenticationEntryPoint((req, rsp, e) -> rsp.sendError(HttpServletResponse.SC_UNAUTHORIZED))
.and()
// Add a filter to validate the tokens with every request
.addFilterAfter(new JwtTokenAuthenticationFilter(jwtConfig), UsernamePasswordAuthenticationFilter.class)
// authorization requests config
.authorizeRequests()
// allow all who are accessing "auth" service
.antMatchers(HttpMethod.POST, jwtConfig.getUri()).permitAll()
// Any other request must be authenticated
.anyRequest().authenticated();
}
@Bean
public JwtConfig jwtConfig() {
return new JwtConfig();
}
}
The
JwtConfig
class will be created later in the common service.
Next, we will write a filter class to authenticate tokens, named JwtTokenAuthenticationFilter
. This class will extend from OncePerRequestFilter
, and this filter will be activated whenever a request is sent.
package com.example.zuulserver.security;
import com.example.commonservice.security.JwtConfig;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.filter.OncePerRequestFilter;
import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.util.List;
import java.util.stream.Collectors;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
public class JwtTokenAuthenticationFilter extends OncePerRequestFilter {
private final JwtConfig jwtConfig;
public JwtTokenAuthenticationFilter(JwtConfig jwtConfig) {
this.jwtConfig = jwtConfig;
}
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
// 1. get the authentication header. Tokens are supposed to be passed in the authentication header
String header = request.getHeader(jwtConfig.getHeader());
// 2. validate the header and check the prefix
if (header == null || !header.startsWith(jwtConfig.getPrefix())) {
filterChain.doFilter(request, response); // if not valid go to the next filter
return;
}
// If there is no token provided and hence the user won't be authenticated.
// It's Ok. Maybe the user accessing a public path or asking for a token.
// All secured paths that needs a token are already defined and secured in config class.
// And If user tried to access without access token, then he won't be authenticated and an exception will be thrown.
// 3. Get the token
String token = header.replace(jwtConfig.getPrefix(), "");
try { // exceptions might be thrown in creating the claims if for example the token is expired
// 4. Validate the token
Claims claims = Jwts.parser().setSigningKey(jwtConfig.getSecret().getBytes()).parseClaimsJws(token).getBody();
String userName = claims.getSubject();
if (userName != null) {
List<String> authorities = (List<String>) claims.get("authorities");
// 5. Create auth object
// UsernamePasswordAuthenticationToken: A built-in object, used by spring to represent the current authenticated / being authenticated user. // It needs a list of authorities, which has type of GrantedAuthority interface, where SimpleGrantedAuthority is an implementation of that interface
UsernamePasswordAuthenticationToken auth = new UsernamePasswordAuthenticationToken(userName, null, authorities.stream().map(SimpleGrantedAuthority::new).collect(Collectors.toList()));
// 6. Authenticate the user
// Now, user is authenticated
SecurityContextHolder.getContext().setAuthentication(auth);
}
} catch (Exception e) {
// In case of failure. Make sure it's clear; so guarantee user won't be authenticated
e.printStackTrace();
SecurityContextHolder.clearContext();
}
// go to the next filter in the filter chain
filterChain.doFilter(request, response);
}
}
And don’t forget to add security config to use the JwtConfig class:
...
security:
jwt:
uri: /auth/**
header: Authorization
prefix: Bearer
expiration: 86400
secret: JwtSecretKey
3. Common service
This service will contain shared configurations for multiple services. We will create a class named JwtConfig to hold the JWT configurations, and this class will be used in both the Auth service and Zuul Gateway.
Similar to creating other services, let’s create a new Spring Boot project and configure the pom.xml
file as follows:
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
Create the JwtConfig
class as follows:
package com.example.commonservice.security;
import org.springframework.beans.factory.annotation.Value;
public class JwtConfig {
@Value("${security.jwt.uri}")
private String uri;
@Value("${security.jwt.header}")
private String header;
@Value("${security.jwt.prefix}")
private String prefix;
@Value("${security.jwt.expiration}")
private int expiration;
@Value("${security.jwt.secret}")
private String secret;
public String getUri() {
return uri;
}
public void setUri(String uri) {
this.uri = uri;
}
public String getHeader() {
return header;
}
public void setHeader(String header) {
this.header = header;
}
public String getPrefix() {
return prefix;
}
public void setPrefix(String prefix) {
this.prefix = prefix;
}
public int getExpiration() {
return expiration;
}
public void setExpiration(int expiration) {
this.expiration = expiration;
}
public String getSecret() {
return secret;
}
public void setSecret(String secret) {
this.secret = secret;
}
}
Next, in other services such as the Zuul gateway, we need to declare the dependency on the common service in the pom.xml
file:
<!-- common dependency-->
<dependency>
<groupId>com.example</groupId>
artifactId>common-service</artifactId>
<version>0.0.1-SNAPSHOT</version>
</dependency>
By adding the common-service dependency to the services that need to use the JwtConfig, you are making sure that multiple services can share this common configuration. This way, you can centralize the configuration while allowing each service to specify their own specific configuration values through their respective application.yml files. This approach enhances reusability and maintainability in your microservices architecture.
Note: *The JwtConfig class will be shared among multiple services, ensuring consistent JWT configuration logic. However, each service can have its own specific configuration values such as security.jwt.uri, security.jwt.header, and others, which can be configured individually in the application.yml file of each service. *
With this in mind, the security setup for the Zuul gateway is completed. Next, you will create the Auth service.
4. Auth Service
In the auth service, we will perform two tasks: one is to authenticate the provided user credentials, and the other is to generate a token if the authentication is successful or return an exception if it’s not valid.
In the pom.xml
file, you will need the following dependencies for the auth service: Web, Eureka Client, Spring Security, and JWT.
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-netflix-eureka-client</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt</artifactId>
<version>0.9.0</version>
</dependency>
<!--common dependency-->
<dependency>
<groupId>com.example</groupId>
<artifactId>common-service</artifactId>
<version>0.0.1-SNAPSHOT</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
The application.yml
file is declared as follows:
server:
port: 8082
eureka:
client:
service-url:
defaultZone: http://localhost:8761/eureka
spring:
application:
name: auth-service
cloud:
config:
uri: http://localhost:8888
security:
jwt:
uri: /auth/**
header: Authorization
prefix: Bearer
expiration: 86400
secret: JwtSecretKey
Inside the application class, we also need to declare it as an Eureka client and annotate it with @EnableWebSecurity
:
package com.example.authservice;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cloud.netflix.eureka.EnableEurekaClient;
@SpringBootApplication
@EnableEurekaClient
public class AuthServiceApplication {
public static void main(String[] args) {
SpringApplication.run(AuthServiceApplication.class, args);
}
}
Similar to configuring the Gateway port, we need to create a class that extends from WebSecurityConfigurerAdapter
:
package com.example.authservice.security;
import com.example.commonservice.security.JwtConfig;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import javax.servlet.http.HttpServletResponse;
import javax.ws.rs.HttpMethod;
@EnableWebSecurity
public class SecurityCredentialsConfig extends WebSecurityConfigurerAdapter {
@Autowired
private UserDetailsService userDetailsService;
@Autowired
private JwtConfig jwtConfig;
@Override
protected void configure(HttpSecurity http) throws Exception {
http
.csrf().disable()
// make sure we use stateless session; session won't be used to store user's state.
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.and()
// handle an authorized attempts
.exceptionHandling().authenticationEntryPoint((req, rsp, e) -> rsp.sendError(HttpServletResponse.SC_UNAUTHORIZED))
.and()
// Add a filter to validate user credentials and add token in the response header
// What's the authenticationManager()?
// An object provided by WebSecurityConfigurerAdapter, used to authenticate the user passing user's credentials
// The filter needs this auth manager to authenticate the user.
.addFilter(new JwtUsernameAndPasswordAuthenticationFilter(authenticationManager(), jwtConfig))
.authorizeRequests()
// allow all POST requests
.antMatchers(HttpMethod.POST, jwtConfig.getUri()).permitAll()
.antMatchers(HttpMethod.GET, "/auth/test").permitAll()
// any other requests must be authenticated
.anyRequest().authenticated();
}
// Spring has UserDetailsService interface, which can be overriden to provide our implementation for fetching user from database (or any other source).
// The UserDetailsService object is used by the auth manager to load the user from database. // In addition, we need to define the password encoder also. So, auth manager can compare and verify passwords. @Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.userDetailsService(userDetailsService).passwordEncoder(passwordEncoder());
}
@Bean
public BCryptPasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Bean
public JwtConfig jwtConfig(){
return new JwtConfig();
}
}
In the above code snippet, we use the UserDetailsService
interface, so we need to implement it:
package com.example.authservice.security;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.AuthorityUtils;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.stereotype.Service;
import java.util.Arrays;
import java.util.List;
@Service
public class UserDetailsServiceImpl implements UserDetailsService {
@Autowired
private BCryptPasswordEncoder encoder;
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
// TODO get user by username in db currently we hardcode user for test
// hard coding the users. All passwords must be encoded.
final List<AppUser> users = Arrays.asList(
new AppUser(1, "user", encoder.encode("12345"), "USER"),
new AppUser(2, "admin", encoder.encode("12345"), "ADMIN")
);
for(AppUser appUser: users) {
if(appUser.getUsername().equals(username)) {
// Remember that Spring needs roles to be in this format: "ROLE_" + userRole (i.e. "ROLE_ADMIN")
// So, we need to set it to that format, so we can verify and compare roles (i.e. hasRole("ADMIN")).
List<GrantedAuthority> grantedAuthorities = AuthorityUtils
.commaSeparatedStringToAuthorityList("ROLE_" + appUser.getRole());
// The "User" class is provided by Spring and represents a model class for user to be returned by UserDetailsService
// And used by auth manager to verify and check user authentication.
return new User(appUser.getUsername(), appUser.getPassword(), grantedAuthorities);
}
}
// If user not found. Throw this exception.
throw new UsernameNotFoundException("Username: " + username + " not found");
}
// A (temporary) class represent the user saved in the database.
private static class AppUser {
private Integer id;
private String username, password;
private String role;
public AppUser(Integer id, String username, String password, String role) {
this.id = id;
this.username = username;
this.password = password;
this.role = role;
}
public Integer getId() {
return id;
}
public void setId(Integer id) {
this.id = id;
}
public String getUsername() {
return username;
}
public void setUsername(String username) {
this.username = username;
}
public String getPassword() {
return password;
}
public void setPassword(String password) {
this.password = password;
}
public String getRole() {
return role;
}
public void setRole(String role) {
this.role = role;
}
}
}
The next and final step involves implementing a filter. Here, we use the JwtUsernameAndPasswordAuthenticationFilter
to authenticate user credentials and generate tokens. User information such as the username and password must be sent as a POST request.
package com.example.authservice.security;
import com.example.commonservice.security.JwtConfig;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import org.springframework.security.web.util.matcher.AntPathRequestMatcher;
import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.util.Collections;
import java.util.Date;
import java.util.stream.Collectors;
public class JwtUsernameAndPasswordAuthenticationFilter extends UsernamePasswordAuthenticationFilter {
// We use auth manager to validate the user credentials
private AuthenticationManager authManager;
private final JwtConfig jwtConfig;
public JwtUsernameAndPasswordAuthenticationFilter(AuthenticationManager authManager, JwtConfig jwtConfig) {
this.authManager = authManager;
this.jwtConfig = jwtConfig;
// By default, UsernamePasswordAuthenticationFilter listens to "/login" path.
// In our case, we use "/auth". So, we need to override the defaults.
this.setRequiresAuthenticationRequestMatcher(new AntPathRequestMatcher(jwtConfig.getUri(), "POST"));
}
@Override
public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response)
throws AuthenticationException {
try {
// 1. Get credentials from request
UserCredentials creds = new ObjectMapper().readValue(request.getInputStream(), UserCredentials.class);
// 2. Create auth object (contains credentials) which will be used by auth manager
UsernamePasswordAuthenticationToken authToken = new UsernamePasswordAuthenticationToken(
creds.getUsername(), creds.getPassword(), Collections.emptyList());
// 3. Authentication manager authenticate the user, and use UserDetialsServiceImpl::loadUserByUsername() method to load the user.
return authManager.authenticate(authToken);
} catch (IOException e) {
throw new RuntimeException(e);
}
}
// Upon successful authentication, generate a token.
// The 'auth' passed to successfulAuthentication() is the current authenticated user. @Override
protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain,
Authentication auth) throws IOException, ServletException {
Long now = System.currentTimeMillis();
String token = Jwts.builder()
.setSubject(auth.getName())
// Convert to list of strings.
// This is important because it affects the way we get them back in the Gateway.
.claim("authorities", auth.getAuthorities().stream()
.map(GrantedAuthority::getAuthority).collect(Collectors.toList()))
.setIssuedAt(new Date(now))
.setExpiration(new Date(now + jwtConfig.getExpiration() * 1000)) // in milliseconds
.signWith(SignatureAlgorithm.HS512, jwtConfig.getSecret().getBytes())
.compact();
// Add token to header
response.addHeader(jwtConfig.getHeader(), jwtConfig.getPrefix() + token);
}
// A (temporary) class just to represent the user credentials
private static class UserCredentials {
private String username, password;
public String getUsername() {
return username;
}
public void setUsername(String username) {
this.username = username;
}
public String getPassword() {
return password;
}
public void setPassword(String password) {
this.password = password;
}
}
}
In the Auth service, we have also utilized the JwtConfig to avoid code duplication.
5. Testing
Run the Eureka, Zuul, Auth, and User services in sequence.
First, let’s try accessing the user service through the path localhost:8762/user/user-info
without a token. You will receive a 401 error as follows:
{
"timestamp": "2023-08-11T09:26:08.131+0000",
"status": 401,
"error": "Unauthorized",
"message": "No message available",
"path": "/user/user-info"
}
To obtain a token, we need to call the authentication API at localhost:8762/auth
Now, call the user service again with a token included in the header:
Furthermore, you can try running multiple instances of the user service to test how requests are distributed. In the next section, we will explore error handling and monitoring in the microservice architecture.