

Building microservices with Netflix OSS, Apache Kafka and Spring Boot – Part 4: Security
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 is 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:
And here are the changes I did after the 3rd part of the blog post to accomplish the goal:
- 1. Build 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) - 2. 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) - 3. Update 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 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
1 2 |
keytool -genkeypair -alias ms-auth -keyalg RSA -keypass ms-auth-pass -keystore ms-auth.jks -storepass ms-auth-pass |
Public key
1 |
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
1 2 3 4 |
<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 huge amount of requests to 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
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 |
@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 database for the authentication we need to implement another set of classes provided by spring UserDetails, UserDetailsService and DaoAuthenticationProvider.
/Account.java
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
@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
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
@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
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 |
@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 for the initialization. There will also add an insert of the client_id that will use for our requests.
/schema.sql
1 2 3 4 5 6 7 |
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); |
In 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 same event with different consumers (ms-mail and ms-auth) we need to have different consumer group-ids, so will need update of the configuration files:
/ms-auth.yml
1 2 3 4 |
spring: kafka: consumer: group-id: ms_auth_consumer |
/ms-mail.yml
1 2 3 4 |
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 on start. Both users has 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
As in the previous parts there were 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
1 2 3 |
security: basic: enabled: true |
After enabling the security, need to “teach” the resource server (ms-user) how to verify the tokens we pass to it. It should be build the same way as the in the ms-auth AuthorizationServerConfigurerAdapter, using the public key we previously generated.
/ResourceConfig.java
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 |
@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 the operation endpoints is very easy with Spring security. Just need enable it for the whole project with the @EnableGlobalMethodSecurity annotation.
1 |
@EnableGlobalMethodSecurity(prePostEnabled = true) |
And then to use it according the needs we have. In our case it will be
1 2 3 4 5 6 |
@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, and
1 2 3 4 5 6 |
@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
1 2 3 4 |
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
1 2 3 4 |
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:
1 2 3 4 |
curl --request GET \ --url http://localhost:8081/members/1 \ --header 'authorization: Bearer USER_ACCESS_TOKEN' \ --header 'content-type: application/json' |
5. Do a “get all” request with the user token, and verify you are getting 403:
1 2 3 4 |
{ "error": "access_denied", "error_description": "Access is denied" } |
Ms-gateway
In the gateway service will do 2 modifications.
First will add filtering for all POST request responses and will check if the body contains contain the refresh and access tokens. If we find them then 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. Also will set different path for the refresh token. Will set it 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 / hour). Also will have an access token with fairly short live (if stolen) but still will not need to log-in every time it expires
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 |
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.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 |
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 Authorization header by extracting the token from the cookie
1 2 3 4 5 6 7 8 9 |
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 some REST client that supports cookies. I personally prefer insomnia.
2. Do a call to request the token for user. See here you don’t need the Authorization header. It is added buy the gateway :
1 2 3 4 5 6 |
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 too, as the token it will be passed with cookie and will be added by the gateway :
1 2 3 |
curl --request GET \ --url http://localhost:8765/api/user/members/1 \ # --cookie access_token=AUTOMATICLI_ADDED |
You can find more information on the topic in the previous parts: