Over a million developers have joined DZone.
{{announcement.body}}
{{announcement.title}}

Building Microservices With Netflix OSS, Apache Kafka and Spring Boot - Part 4: Security

DZone's Guide to

Building Microservices With Netflix OSS, Apache Kafka and Spring Boot - Part 4: Security

After building a group of microservices with Spring Boot in this tutorial, now we'll learn how to secure them with OAuth 2.0 and JWT tokens.

· Microservices Zone ·
Free Resource

Learn how modern cloud architectures use of microservices has many advantages and enables developers to deliver business software in a CI/CD way.


After building our group of microservices, it seems the next step is spending some time for securing them. From my experience at Dreamix, Spring security and JWT tokens are the recipe for success for a spring microservice project, so I will follow it too. I separated this part in a new branch, microservices/auth, that may be found in GIT. You can find also the test requests exported from insomnia in the microservices_insomnia.json file in the project.

Here are the changes I did after the 3rd part of the blog post to accomplish the goal:

  • Build an auth server that will be able to:
    • Issue signed JWT.
    • Save a new user when it is registered (USER_REGISTERED message is sent to Kafka).
  • Update the gateway service to be able to:
    • Manage cookies where we will store the tokens (access and refresh).
    • Enrich the requests with some data (secret).
  • Update the User service to be able to:
    • Verify that the token is issued by our auth server.
    • Secure the "get all users" operation to be accessible by registered users with admin rights (ROLE_ADMIN).
    • Secure the "find by id" operation to be accessible by registered users with user rights (ROLE_USER).

Auth Server

First, we need to generate a certificate public and private pair. It will be used by the auth server to sign the token, then by the resource servers (ms-user) to verify it.

Private key
keytool -genkeypair -alias ms-auth -keyalg RSA -keypass ms-auth-pass -keystore ms-auth.jks
 -storepass ms-auth-pass

Public key

keytool -list -rfc --keystore ms-auth.jks | openssl x509 -inform pem -pubkey

Then we are going to create the authorization server as a separate spring boot project. Here the only new dependency we need is oauth2, plus the others: eureka discovery, config client, jpa, web, H2 and Kafka used in the previous parts.

/pom.xml
<dependency>
            <groupId>org.springframework.cloud</groupId>
            <artifactId>spring-cloud-starter-oauth2</artifactId>
        </dependency>

We are configuring the the OAuth 2.0 Authorization Server mechanism by adding the @EnableAuthorizationServer annotation and implementing AuthorizationServerConfigurer. Spring provides the AuthorizationServerConfigurerAdapter implementation with empty configure() methods that we will overwrite. By default Spring security will provide access_token and refresh_token in UUID format that is expected to be verified by the resource providers (ms-user), using the auth server API. This may turn the auth server into a bottleneck if we have a huge amount of requests for our services. That's why we will make our auth server to issue signed JWTs that will contain all the information necessary to validate the user in the token itself. To achieve that we need JwtTokenStore, JwtAccessTokenConverter, and DefaultTokenServices.

/AuthorizationConfig.java

@Configuration
@EnableAuthorizationServer
public class AuthorizationConfig extends AuthorizationServerConfigurerAdapter {

    @Autowired
    @Qualifier("dataSource")
    private DataSource dataSource;

    @Autowired
    private AuthenticationManager authenticationManager;

    @Override
    public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception {
        endpoints
                .authenticationManager(this.authenticationManager)
                .tokenServices(tokenServices())
                .tokenStore(tokenStore())
                .accessTokenConverter(accessTokenConverter());
    }

    @Override
    public void configure(ClientDetailsServiceConfigurer clients) throws Exception {
        clients.jdbc(dataSource);
    }

    @Bean
    public TokenStore tokenStore() {
        return new JwtTokenStore(accessTokenConverter());
    }

    @Bean
    public JwtAccessTokenConverter accessTokenConverter() {
        JwtAccessTokenConverter converter = new JwtAccessTokenConverter();
        KeyStoreKeyFactory keyStoreKeyFactory =
                new KeyStoreKeyFactory(
                        new ClassPathResource("ms-auth.jks"),
                        "ms-auth-pass".toCharArray());
        converter.setKeyPair(keyStoreKeyFactory.getKeyPair("ms-auth"));
        return converter;
    }

    @Bean
    @Primary
    public DefaultTokenServices tokenServices() {
        DefaultTokenServices defaultTokenServices = new DefaultTokenServices();
        defaultTokenServices.setTokenStore(tokenStore());
        defaultTokenServices.setSupportRefreshToken(true);
        defaultTokenServices.setTokenEnhancer(accessTokenConverter());
        return defaultTokenServices;
    }
}

To make the аuth server using a database for the authentication, we need to implement another set of classes provided by spring UserDetails, UserDetailsService and DaoAuthenticationProvider.

/Account.java
@Entity
public class Account implements UserDetails {

    @Id
    @GeneratedValue(strategy = GenerationType.SEQUENCE)
    private Long id;
    @Column(unique = true)
    private String username;
    @JsonIgnore
    private String password;
    @Enumerated(EnumType.STRING)
    @ElementCollection(fetch = FetchType.EAGER)
    private List<Role> roles;
    private boolean accountNonExpired, accountNonLocked, credentialsNonExpired, enabled;
}

/AccountService.java

@Service
public class AccountService implements UserDetailsService {

    @Autowired
    private AccountRepository accountRepository;

    @Override
    public UserDetails loadUserByUsername(String s) throws UsernameNotFoundException {
        Optional<Account> account = accountRepository.findByUsername(s);
        if (account.isPresent()) {
            return account.get();
        } else {
            throw new UsernameNotFoundException(String.format("Username: %s not found", s));
        }
    }

/SecurityConfig.java

@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    @Autowired
    private PasswordEncoder passwordEncoder;

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

    @Bean
    public UserDetailsService userDetailsService() {
        return new AccountService();
    }

    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        auth
                .userDetailsService(userDetailsService())
                .passwordEncoder(passwordEncoder);
    }
}

There are also 6 tables that Spring needs for storing security data in the database. With Spring Boot, we just need to put the queries in a schema.sql file in the resources and it will take care of the initialization. There will also add an insert of the client_id that will be used for our requests.

/schema.sql

INSERT INTO oauth_client_details
(client_id, client_secret, scope, authorized_grant_types,
 web_server_redirect_uri, authorities, access_token_validity,
 refresh_token_validity, additional_information, autoapprove)
VALUES
  ('trusted-app', 'secret', 'read,write',
   'password,client_credentials,refresh_token', NULL, NULL, 86400, 2592000, NULL, TRUE);

The auth service will also have a consumer, same as in the email service (ms-email), that will insert a new record in the auth-server database when the USER_CREATED message is sent to Kafka by the ms-user microservice. To be able to consume the same event with different consumers (ms-mail and ms-auth), we need to have different consumer group-ids, so will need to update the configuration files:

/ms-auth.yml
spring:
  kafka:
    consumer:
      group-id: ms_auth_consumer

/ms-mail.yml

spring:
  kafka:
    consumer:
      group-id: ms_mail_consumer


To test if everything is fine, build and run the project:

1. Build and run the service. By default, the auth server is configured to run at port 9999, and there are 2 users added - admin and user inserted at start. Both users have the password "password."

2. Encode the secret to Base64 format "trusted-app:secret" goes to dHJ1c3RlZC1hcHA6c2VjcmV0.

3. Do a call to request the token for user:

curl -request POST \  
-url http://localhost:9999/oauth/token \
-header 'authorization: Basic dHJ1c3RlZC1hcHA6c2VjcmV0′ \
-header 'content-type: multipart/form-data \
-form grant_type=password \
-form username=user \
-form password=password

4. Get the access_token result go to https://jwt.io/ and verify the signature with the public key.

5. Do the same for the admin user ( -form username=admin \ ).

User Service

In the previous parts, there was no security mechanism, so there is a property in our configuration that needs to be changed now to enable it. In the config, properties should set security.basic.enabled from false to true.

/ms-user.yml

security:
  basic:
    enabled: true

After enabling the security, we need to "teach" the resource server (ms-user) how to verify the tokens we pass to it. It should be built the same way as the in the ms-auth AuthorizationServerConfigurerAdapter, using the public key we previously generated.

/ResourceConfig.java
@Configuration
@EnableResourceServer
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class ResourceConfig extends ResourceServerConfigurerAdapter {

    @Override
    public void configure(ResourceServerSecurityConfigurer resources) {
        resources
                .tokenServices(tokenServices())
                .tokenStore(tokenStore());
    }

    @Override
    public void configure(HttpSecurity http) throws Exception {
        http
                .csrf().disable()
                .authorizeRequests()
                .antMatchers(HttpMethod.OPTIONS).permitAll()
                // allow registering by anyone
                .antMatchers(HttpMethod.POST, "/members").permitAll()
                // restrict access to authenticated users
                .antMatchers("/**").authenticated();
    }

    @Bean
    public TokenStore tokenStore() {
        return new JwtTokenStore(accessTokenConverter());
    }

    @Bean
    public JwtAccessTokenConverter accessTokenConverter() {
        JwtAccessTokenConverter converter = new JwtAccessTokenConverter();
        Resource resource = new ClassPathResource("ms-auth.cert");
        String publicKey;
        try {
            publicKey = new String(FileCopyUtils.copyToByteArray(resource.getInputStream()));
        } catch (IOException e) {
            throw new RuntimeException(e);
        }
        converter.setVerifierKey(publicKey);
        return converter;
    }

    @Bean
    public DefaultTokenServices tokenServices() {
        DefaultTokenServices defaultTokenServices = new DefaultTokenServices();
        defaultTokenServices.setTokenStore(tokenStore());
        defaultTokenServices.setSupportRefreshToken(true);
        defaultTokenServices.setTokenEnhancer(accessTokenConverter());
        return defaultTokenServices;
    }
}

Setting different levels of security for the operation endpoints is very easy with Spring security. We just need to enable it for the whole project with the @EnableGlobalMethodSecurity annotation:

@EnableGlobalMethodSecurity(prePostEnabled = true)

And then to use it according to the needs we have. In our case, it will be:

@PreAuthorize("hasAuthority('ROLE_USER')")
    @RequestMapping(method = RequestMethod.GET, path = "/members/{id}")
    public ResponseEntity<User> findByUserId(@PathVariable("id") Long id) {
        User result = userService.findById(id);
        return new ResponseEntity<>(result, HttpStatus.OK);
    }

To secure the "find by id" operation to be accessible by registered users with ROLE_USER authority:

 @PreAuthorize("hasAuthority('ROLE_ADMIN')")
    @RequestMapping(method = RequestMethod.GET, path = "/members")
    public ResponseEntity<Iterable<User>> getAll() {
        Iterable<User> all = userService.findAll();
        return new ResponseEntity<>(all, HttpStatus.OK);
    }

To secure the "get all users" operation to be accessible by registered users with admin rights.

To test if everything is fine, build and run the project:

1. Build and run the service. By default, the user service runs on port 8081 and has no registered users (the service user and admin are only in the auth service).

2. Register 1 new user:

curl --request POST \
  --url http://localhost:8081/members \
  --header 'content-type: application/json' \
  --data '{\n"username": "test",\n"password": "pass"\n}'

3. Do a "get all" request with the admin token:

curl --request GET \
  --url http://localhost:8081/members \
  --header 'authorization: Bearer ADMIN_ACCESS_TOKEN' \
  --header 'content-type: application/json'

4. Do a "find by id" request with the user token:

curl --request GET \
  --url http://localhost:8081/members/1 \
  --header 'authorization: Bearer USER_ACCESS_TOKEN' \

5. Do a "get all" request with the user token, and verify you are getting 403:

Ms-gateway

In the gateway service, we will do 2 modifications. The first will add filtering for all POST request responses and check if the body contains contain the refresh and access tokens. If we find them, then it will create 2 cookies. The refresh token cookie will be a little different than the access one. Its age will be bigger (30 days) while the access token cookie is 1h. It also will set different path for the refresh token. This will set it the same as the endpoint for issuing tokens (/auth/oauth/token). That way the refresh token will not be sent with every request but only when we need to get a new access token (once per hour). It also will have an access token with fairly short live (if stolen) but still will not need to log in every time it expires.

  try {
            final InputStream is = ctx.getResponseDataStream();
            String responseBody = IOUtils.toString(is, "UTF-8");
            if (responseBody.contains(refreshTokenCookieName) && responseBody.contains(accessTokenCookieName)) {
                final Map<String, Object> responseMap = mapper.readValue(responseBody,
                        new TypeReference<Map<String, Object>>() {
                        });
                final String refreshToken = responseMap.get(refreshTokenCookieName).toString();
                final String accessToken = responseMap.get(accessTokenCookieName).toString();

                final Cookie refreshTokenCookie = new Cookie(refreshTokenCookieName, refreshToken);
                refreshTokenCookie.setPath(ctx.getRequest().getContextPath() + tokenPath);
                refreshTokenCookie.setMaxAge(refreshTokenMaxAge);
                ctx.getResponse().addCookie(refreshTokenCookie);
                logger.info("refresh token = " + refreshToken);

                final Cookie accessTokenCookie = new Cookie(accessTokenCookieName, accessToken);
                accessTokenCookie.setPath(ctx.getRequest().getContextPath() + "/");
                accessTokenCookie.setMaxAge(accessTokenMaxAge);
                ctx.getResponse().addCookie(accessTokenCookie);
                logger.info("access token = " + accessToken);

            }
            ctx.setResponseBody(responseBody);
        }

Then will add filtering for any request to the auth server for issuing tokens (to oauth/token). And will add the secret to the request. This will prevent exposing the secret to the front end.

        if (ctx.getRequest().getRequestURI().equals(tokenPath)) {

            byte[] encoded;

            try {
                encoded = Base64.encode(clientSecret.getBytes("UTF-8"));
                ctx.addZuulRequestHeader("Authorization", "Basic " + new String(encoded));

                final HttpServletRequest req = ctx.getRequest();
                String grantType = ctx.getRequest().getParameter("grant_type");

                if ("refresh_token".equals(grantType)) {
                    logger.info("getting refresh_token");

                    final String refreshToken = extractRefreshToken(req);
                    final Map<String, String[]> param = new HashMap<String, String[]>();
                    param.put("refresh_token", new String[]{refreshToken});

                    ctx.setRequest(new CustomHttpServletRequest(req, param));
                }
                if ("password".equals(grantType)) {
                    logger.info("getting password");
                }
            } catch (UnsupportedEncodingException e1) {
                logger.error("Error occured in pre filter", e1);
            }
        }

For any other requests (to services different from the auth server), the gateway will add an Authorization header by extracting the token from the cookie.

else {
            logger.info("getting " + ctx.getRequest().getRequestURI());
            final HttpServletRequest req = ctx.getRequest();
            final String accessToken = extractAccessToken(req);

            if (accessToken != null) {
                ctx.addZuulRequestHeader("Authorization", "Bearer " + new String(accessToken));
            }
        }

To test if everything is fine, build and run the project:

1. Build and run the service. By default, the gateway service runs on port 8765. The easiest way to perform the next steps is to use a REST client that supports cookies. I personally prefer insomnia.
2. Do a call to request the token for user. Here, you don't need the Authorization header. It is added by the gateway:

curl --request POST \
  --url http://localhost:8765/auth/oauth/token \
  --header 'content-type: multipart/form-data \
  --form grant_type=password \
  --form username=user \
  --form password=password

3. Do a "find by id" request with the user token in the cookie. Here, you don't need the Authorization header either, as the token will be passed with the cookie and will be added by the gateway:

curl --request GET \
  --url http://localhost:8765/api/user/members/1 \
#  --cookie access_token=AUTOMATICLI_ADDED


Discover how to deploy pre-built sample microservices OR create simple microservices from scratch.

Topics:
microservices ,tutorial ,netflix oss ,apache kafka ,spring boot ,security

Published at DZone with permission of

Opinions expressed by DZone contributors are their own.

{{ parent.title || parent.header.title}}

{{ parent.tldr }}

{{ parent.urlSource.name }}