In this article, I will demonstrate how to configure a two-factor authentication feature for the SpringBoot 2.5OAuthJWT server, which is part of a larger SpringCloud system.

We built a distributed CRM system. The system has dedicated Springboot-based authentication/authorization services and many secure and insecure microservices. We choose the JWT security mechanism of the authorization server to validate any request from each secure microservice, which is the job of the microservice. In addition, we want to enable 2FA authentication for the authorization service before it issues a valid JWT to achieve another level of protection.

The staffing arrangement is as follows. First, I described the various parts of the system. Second, I demonstrated the architecture of the system and described how to configure the authorization server to enable 2FA. Provides the entire system source code. Here. Part 2 of this article demonstrates how authorization servers work behind the scenes.

system

The composition diagram of our system is shown in Figure 1. Users interact with the system through the Zuul gateway. Users can also call individual microservice Restapis directly.

The Config server provides configuration files for microservices. The global profile contains information about all microservices. There are also configuration files for individual microservices. For this to work automatically, all services are registered on the Eureka server. Some micro-services are connected to each other through the Kafka message broker. Kafka’s hosts, ports, and topics are listed in the global configuration file.

This system is based on starter. Iskelon Ivanov of code; See his post for details on how the system works. I added a two-factor authorization and migrated the authorization server from 1.5 to SpringBoot2.5. In addition, I analyzed how the authorization server works in Part 2.

In this distributed system, the secure workflow is more complex than in a monolithic system, where the user only needs to provide a username and password, and possibly a 2FA code. Our system uses implicit authorization flow. To get a valid JWT, the user needs to provide a client ID(customer (for brevity) and customer secret (secret, for brevity, are microservice “credentials”. In addition, he or she needs to provide a valid user name and password – these are the usual credentials we are used to. In addition, if the user has his/her 2FA enabled, the user needs to send an additional request with the same content. Customer: Confidential and provide the necessary 2FA codes. Finally, to refresh the JWT, the user must provide the customer: secret and valid refresh token.

The client ID, client secret, user name, and password are stored in a separate database, connected only to the authorization server. In addition, the authorization server retains a private key to sign. All security services retain a copy of the corresponding public key to verify the signature. Let’s see how to configure the authorization server.

Authorization server architecture

The actual workflow of 3 scenarios, by Post Anar Sudanov, is shown below. If the user has his/her 2FA disabled, the user can obtain authorization in 1 step. The user makes the following calls:

curl trusted-app:secret@localhost:9999/oauth/token -d grant_type=password -d username=user -d password=password
Copy the code

In this system, localhost:9999 is the host: port where the authorization server is deployed, and /oauth/token is the token endpoint. The grant_type=password is the grant type for the first step of 2FA authorization, and username and password are the usual user credentials. The system returns a valid JWT and a refresh token with the user role encoding.

If the user has his/her 2FA enabled, the user makes two calls. The first call is the same as above; This time, however, the system returns a valid access token with a “Preauth” role encoding. The user then makes another call:

curl trusted-app:secret@localhost:9999/oauth/token -d grant_type=tfa -d tfa_token=$token -d tfa_code=123456
Copy the code

Where $token is the “preauth” access token from the previous step, tFA_code is the one-time code required for the 2FA algorithm to work. The system returns a valid JWT and a refresh token with the user role encoding.

Note that this intermediate step with the access token (not the JWT) prevents the microservice from incorrectly authenticating the user, where the JWT is authenticated with the public key of the JWT, but the service has a “< role >” filter other than All. In this case, a “preauth” role is also acceptable.

Finally, to refresh a JTW(not necessarily expired), the user calls:

curl trusted-app:secret@localhost:9999/oauth/token -d grant_type=refresh_token -d refresh_token=$token
Copy the code

Where we used grant_type=refresh_token and the actual refresh token obtained in the previous step.

Under the hood, our system looks like this (Figure 2).



For incoming token requests, the system first validates themClient: SecretBasic authentication filters on the system, which in turn calls the client and secret authentication manager. If positive, the request will arrive at the authorized endpoint. /oauth/token.The system then invokes the username and password validator, 2FA validator, or refresh token validator, depending on the request type; These validators are used in the appropriate token grantor to actually issue the JWT. In turn, the username and password validator requires the username and password authentication manager to do its job. Let’s look at how to configure these managers.

\

Authentication manager

The client and secret authentication manager are automatically configured by the Spring authorization server. In the… In the second part of this article, I show in detail how this works. Now, we just need for the customer: secret to (see full text) code details. \

@Configuration @EnableAuthorizationServer public class AuthorizationConfig extends AuthorizationServerConfigurerAdapter { @Autowired @Qualifier("dataSource") private DataSource dataSource; {... } @Override public void configure(ClientDetailsServiceConfigurer clients) throws Exception { clients.jdbc(dataSource); } {... }}Copy the code

Next, the client and secret authentication manager are created in the following manner:

@Configuration
@EnableWebSecurity(debug = true)
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    @Autowired
    private PasswordEncoder passwordEncoder;

    @Bean("securityConfigAuthManager")
    @Override
    public AuthenticationManager authenticationManagerBean() throws Exception {
        return super.authenticationManagerBean();
    }

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

    @Bean("securityConfigUserService")
    @Primary
    @Qualifier("userDetail")
    public UserDetailsService userDetailsService() {
        return new AccountServiceImpl();
    }
}
Copy the code

Here, we first validate AccountServiceImpl() for the user name and password by userDetailsService(). Then we can feed the userDetailsService () to configure (AuthenticationManagerBuilder auth) can be used to make service authenticationManagerBean (). It is this authentication manager that automatically enters AuthorizationConfig.

As I demonstrated in Part 2, each child of @enableWebSecurity generates a filter chain. WebSecurityConfigurerAdapter in authorized service and SecurityConfig is no exception. However, for our application, we don’t need this specific filter link. We use only the provided infrastructure to create the username and password authentication manager. Let’s look at how to program the token authorization program.

Token grantor

We need to create the necessary helper classes to make the token grantor work, and then provide the grantor to the token endpoint:

@Configuration @EnableAuthorizationServer public class AuthorizationConfig extends AuthorizationServerConfigurerAdapter { @Autowired private AuthenticationManager authenticationManager; @Autowired private TfaService tfaService; {... } @Override public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception { endpoints.tokenGranter(tokenGranter(endpoints)); } @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 public DefaultTokenServices tokenServices() { DefaultTokenServices defaultTokenServices = new DefaultTokenServices(); defaultTokenServices.setTokenStore(tokenStore()); defaultTokenServices.setSupportRefreshToken(true); defaultTokenServices.setTokenEnhancer(accessTokenConverter()); return defaultTokenServices; } public class RefreshTokenConverter extends JwtAccessTokenConverter{ public MyAccessTokenConverter(){ super(); KeyStoreKeyFactory keyStoreKeyFactory = new KeyStoreKeyFactory( new ClassPathResource("ms-auth.jks"), "ms-auth-pass".toCharArray()); super.setKeyPair(keyStoreKeyFactory.getKeyPair("ms-auth")); } public Map<String, Object> decode(String token){return super.decode(token); } } private TokenGranter tokenGranter(final AuthorizationServerEndpointsConfigurer endpoints) { List<TokenGranter> granters =new ArrayList<>(); granters.add(new PasswordTokenGranter(endpoints, authenticationManager, tfaService, tokenServices())); granters.add(new TfaTokenGranter(endpoints, authenticationManager, tfaService, tokenServices())); granters.add(new JWTRefreshTokenGranter(endpoints, authenticationManager, tfaService, tokenServices(), new RefreshTokenConverter())); return new CompositeTokenGranter(granters); }}Copy the code

Here we set the private key to RefreshTokenConverter this token converter is used to decode the refresh token. Next, we set up another token converter. AccessTokenConverter () has the same private key TokenStore signed JWTS. Then, we give TokenStore to TokenGranter. Finally, TokenGranter offers endpoint configuration program: configure (AuthorizationServerEndpointsConfigurer endpoints).

Each token grantor must implement the Grant (String grantType, TokenRequest TokenRequest) method. The password token authorizer is Grant (…). The implementation of the method is as follows:

public OAuth2AccessToken grant(String grantType, TokenRequest tokenRequest) {
            Map<String, String> parameters = new LinkedHashMap<>(tokenRequest.getRequestParameters());
            String username = parameters.get("username");
            String password = parameters.get("password");
            parameters.remove("password");
            Authentication userAuth = new UsernamePasswordAuthenticationToken(username, password);
            ((AbstractAuthenticationToken) userAuth).setDetails(parameters);
            String clientId = tokenRequest.getClientId();
            ClientDetails client = this.clientDetailsService.loadClientByClientId(clientId);
            this.validateGrantType(grantType, client);
            try {
                userAuth = this.authenticationManager.authenticate(userAuth);
            } catch (AccountStatusException | BadCredentialsException e) {
                throw new InvalidGrantException(e.getMessage());
            }
            if (userAuth != null && userAuth.isAuthenticated()) {
                OAuth2Request storedOAuth2Request = this.getRequestFactory().createOAuth2Request(client, tokenRequest);
                if (tfaService.isEnabled(username)) {
                    userAuth = new UsernamePasswordAuthenticationToken(username, password, Collections.singleton(PRE_AUTH));
                    OAuth2AccessToken accessToken = this.endpointsConfigurer.getTokenServices().createAccessToken(new OAuth2Authentication(storedOAuth2Request, userAuth));
                    return accessToken;
                }
                OAuth2AccessToken jwtToken = this.jwtService.createAccessToken(new OAuth2Authentication(storedOAuth2Request, userAuth));
                return jwtToken;
            } else {
                throw new InvalidGrantException("Could not authenticate user: " + username);
            }
 }
Copy the code

Here we first extract the username: password and client (called clientId here, from client: secret (on) from tokenRequest. The system then calls clientDetailService to load the client details, perform the following operations client and verify the grant type. Next, the authentication manager validates the user name: password pair. Then the system call tfaService to check whether 2FA is enabled for this username, do the following. If it is positive, the system will call tokenService to create an OAuth2Access token using the “Preauth” role encoding and return the token. If not, the system calls jwtService(that’s tokenService from… AuthorizationConfig) to create a JWT and return the token.

The implementation of the TFA Scepter grant() method is as follows:

    @Override
    public OAuth2AccessToken grant(String grantType, TokenRequest tokenRequest){
           return super.grant(grantType, tokenRequest);
    }

    @Override
    protected OAuth2Authentication getOAuth2Authentication(ClientDetails client, TokenRequest tokenRequest) {
      {extracts "tfa_token" parameters}
            OAuth2Authentication authentication = loadAuthentication(tfaToken);
            if (parameters.containsKey("tfa_code")) {
                int code = parseCode(parameters.get("tfa_code"));
                if (tfaService.verifyCode(username, code)) {
                    return getAuthentication(tokenRequest, authentication);
                }
            }
      {elses and throw exceptions}
    }
      
    private OAuth2Authentication loadAuthentication(String accessTokenValue) {
        OAuth2AccessToken accessToken = this.tokenStore.readAccessToken(accessTokenValue);
        {checks if the accessToken is not null or expired} 
            OAuth2Authentication result = this.tokenStore.readAuthentication(accessToken);
            return result;
        }
    }
    private OAuth2Authentication getAuthentication(TokenRequest tokenRequest, OAuth2Authentication authentication) {
       {authManager authenticates the user;
        clientDetailsService verifies clientId}
        return refreshAuthentication(authentication, tokenRequest);
    }

    private OAuth2Authentication refreshAuthentication(OAuth2Authentication authentication, TokenRequest request) {
       {verifies the request scope}
        return new OAuth2Authentication(clientAuth, authentication.getUserAuthentication());
    }
Copy the code

It is long, so details have been omitted for brevity (see code). This token issuer works differently from the previous one. Here, we override the parent method to call tfaService to validate the 2FA code. If yes, the system checks the user and client credentials again to return JWT.

The TfaService is:

 @Autowired
    private AccountRepository accountRepository;
    private GoogleAuthenticator googleAuthenticator = new GoogleAuthenticator();

    public boolean isEnabled(String username) {
        Optional<Account> account = accountRepository.findByUsername(username);
        if(account.isPresent()) {
            return accountRepository.findByUsername(username).get().isTwoFa();
        }
        else return false;
    }

    public boolean verifyCode(String username, int code) {
        Optional<Account> account = accountRepository.findByUsername(username);
        if(account.isPresent()) {
            System.out.println("TFA code is OK");
            return code == googleAuthenticator.getTotpPassword(account.get().getSecret());
        }
        else return false;
    }
Copy the code

The service checks whether the user has enabled his/her 2FA and whether the provided code matches the code that Google authenticators obtained from the user’s secret. Enable 2FA and user secrets both from accountRepository.

Finally, let’s look at the refresh token authorizer:

 public OAuth2AccessToken grant(String grantType, TokenRequest tokenRequest){
        String refrToken = tokenRequest.getRequestParameters().get("refresh_token");
        //decode(refrtoken) verifies the token as well
        Map<String,Object> map = this.jwtConverter.decode(refrToken);
        OAuth2Authentication auth = this.jwtConverter.extractAuthentication(map);
        OAuth2AccessToken result = this.jwtTokenService.createAccessToken(auth);
        return result;
    }
Copy the code

This is easy. The system extracts the refresh token from the request. RefreshConverter(()jwtConverter) then decodes the refresh token. Finally, the system is extracted. OAuth2Authentication auth and create a JWT from auth.

I would like to point out that this warrant grantor is implemented in a different way than the grantor in the original authorization program. Duty. There, in the first 2FA step, the user (with 2FA enabled) uses Customer: Secret, enters “password”, username: password. The system returns an “MFA” (Multi-factor Authorization) access token with a “Preauth” role encoding. In the second 2FA step, the user uses customer: secret, granting type “MFA”, “MFA” to access the token and 2FA code. If validated, the system returns an access token with the user role encoding.

Our system was modified to return a JWT instead of an access token. Also, in the first step of the 2FA process, our system returns an access token, making it impossible to mistakenly authorize a “preauth” JWT as an “except user role” token. Finally, the system performs 2FA authentication in the first 2FA step without needing to compute the expensive “2FA_Required” exception.

The results of

To test the 2FA functionality, we used three container tests, one for each request type. For brevity, I demonstrate how to test the second scenario username when the user first provides him/her: password, then access token and the necessary 2FA code.

MvcResult result = mockMvc.perform(post("/oauth/token")
				.header("Authorization","Basic dHJ1c3RlZC1hcHA6c2VjcmV0")
				.param("username","admin")
				.param("password","password")
				.param("grant_type","password")
				.contentType(MediaType.MULTIPART_FORM_DATA_VALUE)
		)
				.andExpect(status().isOk())
				.andReturn();

		String resp = result.getResponse().getContentAsString();
		String token = getTokenString("tfa_token",resp);

		result = mockMvc.perform(post("/oauth/token")
				.header("Authorization","Basic dHJ1c3RlZC1hcHA6c2VjcmV0")
				.param("tfa_code","123456")
				.param("tfa_token",token)
				.param("grant_type","tfa")
				.contentType(MediaType.MULTIPART_FORM_DATA_VALUE)
		)
				.andExpect(status().isOk())
				.andReturn();

		resp = result.getResponse().getContentAsString();
		String tokenBody = getTokenString("access_token",resp);
		String user = getUserName(tokenBody);
		String auth = getAuthorities(tokenBody);
		assertEquals(user, "admin");
		assertTrue(auth.contains("ROLE_USER"));
		assertTrue(auth.contains("ROLE_ADMIN"));
Copy the code

Here is the simulated curl call for the second scenario. This tfaService. Verify (String username, int code) returns true for each code so that the test runs automatically. The test passed. See the other two tests.

conclusion

In this article, I demonstrated how to configure the Spring authorization server to enable 2FA functionality. To do this, we need to program three token grantors and two authentication managers, for one of which we only need to set up a client details data source. Hope to see you in part two, where I demonstrate how the Spring authorization server runs all of this behind the scenes.

The last

Thank you for watching, if you think my writing is good, you can like + favorites! More Java advanced learning materials, 2022 big factory interview real questions, complete information has been packed to everyone, need information partners pay attention to the blog + private letter “learning” or “face by” can be obtainedFree information!!!!!