DZone
Thanks for visiting DZone today,
Edit Profile
  • Manage Email Subscriptions
  • How to Post to DZone
  • Article Submission Guidelines
Sign Out View Profile
  • Post an Article
  • Manage My Drafts
Over 2 million developers have joined DZone.
Log In / Join
Please enter at least three characters to search
Refcards Trend Reports
Events Video Library
Refcards
Trend Reports

Events

View Events Video Library

Zones

Culture and Methodologies Agile Career Development Methodologies Team Management
Data Engineering AI/ML Big Data Data Databases IoT
Software Design and Architecture Cloud Architecture Containers Integration Microservices Performance Security
Coding Frameworks Java JavaScript Languages Tools
Testing, Deployment, and Maintenance Deployment DevOps and CI/CD Maintenance Monitoring and Observability Testing, Tools, and Frameworks
Culture and Methodologies
Agile Career Development Methodologies Team Management
Data Engineering
AI/ML Big Data Data Databases IoT
Software Design and Architecture
Cloud Architecture Containers Integration Microservices Performance Security
Coding
Frameworks Java JavaScript Languages Tools
Testing, Deployment, and Maintenance
Deployment DevOps and CI/CD Maintenance Monitoring and Observability Testing, Tools, and Frameworks

Last call! Secure your stack and shape the future! Help dev teams across the globe navigate their software supply chain security challenges.

Modernize your data layer. Learn how to design cloud-native database architectures to meet the evolving demands of AI and GenAI workloads.

Releasing software shouldn't be stressful or risky. Learn how to leverage progressive delivery techniques to ensure safer deployments.

Avoid machine learning mistakes and boost model performance! Discover key ML patterns, anti-patterns, data strategies, and more.

Related

  • Spring OAuth Server: Token Claim Customization
  • Authentication With Remote LDAP Server in Spring Web MVC
  • Authentication with Spring Boot and Spring Security — JWT and Postgres
  • Develop a Secure CRUD Application Using Angular and Spring Boot

Trending

  • It’s Not About Control — It’s About Collaboration Between Architecture and Security
  • How Large Tech Companies Architect Resilient Systems for Millions of Users
  • AI’s Role in Everyday Development
  • Performing and Managing Incremental Backups Using pg_basebackup in PostgreSQL 17
  1. DZone
  2. Coding
  3. Frameworks
  4. How to Implement Two-Factor Authentication in a Spring Boot OAuth Server? Part 1: Configuration

How to Implement Two-Factor Authentication in a Spring Boot OAuth Server? Part 1: Configuration

In this post, I demonstrate how to configure a two-factor authentication functionality for a Spring Boot 2.5 OAuth JWT server, that is a part of a larger Spring Cloud system.

By 
Alexander Eleseev user avatar
Alexander Eleseev
DZone Core CORE ·
Sep. 14, 21 · Tutorial
Likes (1)
Comment
Save
Tweet
Share
9.5K Views

Join the DZone community and get the full member experience.

Join For Free

We build a distributed CRM system. The system has a dedicated Spring Boot-based authentication/authorization service and a number of secure and insecure micro-services. We choose the JWT security mechanism for the authorization server not to verify any request for every secure micro-service; it is the micro-services job to do this. Also, we want to enable a 2fa authentication for the authorization service for another level of protection before a valid JWT is issued by the authorization service.

The post is organized as follows. First, I describe the system and its parts. Second, I demonstrate the system's architecture and describe how to configure the authorization server to enable 2fa. The source code of the whole system is provided here. Part 2 of this post demonstrates how the authorization server works under the hood.

System

The component diagram of our system is depicted in Fig 1. The user interacts with the system via a Zuul gateway. The user can call individual micro-service REST APIs directly as well.

The Config server provides config files for the micro-services. The global config file contains information for all the micro-services. There are also config files specific for individual micro-services. For this to work automatically, all the services register themselves on a Eureka server. Some of the micro-services are connected with each other via a Kafka message broker. The Kafka's host, port, and topics are listed in the global config file.

This system is based on the starter code of Iskren Ivanov; see his post for the details of how this system works. I added a two-factor authorization and migrated the authorization server to Spring Boot 2.5 up from 1.5. Also, I analyzed how the authorization server works under the hood in Part 2.

The Component Diagram of Our System

Fig 1. The component diagram of our system.

Security workflow in this distributed system is more involved than in a monolithic one, where a user needs only to provide his/her username and password, and, maybe, a 2fa code. Our system uses an implicit authorization flow. To get a valid JWT, the user needs to provide a Client Id (client for brevity) and Client Secret (secret for brevity); these are micro-service "credentials". Also, he or she needs to provide a valid username and password - these are the regular credentials we are used to. Also, if the user has his/her 2fa enabled, the user needs to send an extra request with the same client:secret and provide the necessary 2fa code. Finally, to refresh a JWT, the user has to provide the client:secret and a valid refresh token.

The Client Id, Client Secret, username, and password are stored in a separate database, connected only to the authorization server. Also, the authorization server keeps a private key to sign JWTs; all secure services keep a copy of the corresponding public key to verify the signature. Let's see how to configure this authorization server.

Authorization Server Architecture

The actual workflow for the 3 scenarios, inspired by the post of Anar Sultanov, is as follows.  If the user has his/her 2fa disabled, the user gets authorized in 1 step. The user makes the following call:

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


Here trusted-app:secret is the client:secret in this system, localhost:9999 are the host:port where the authorization server is deployed, /oauth/token is the token endpoint. The grant_type=password is the grant type for the first step of 2fa authorization, username and password are usual user credentials. The system returns a valid JWT and a refresh token with the user's role encoded.

If the user has his/her 2fa enabled, the user makes 2 calls. The first call is identical to the above one; this time, however, the system returns a valid access token with a "pre_auth" role encoded. Then, the user makes another call:

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


Where the $token is the "pre_auth" access token from the previous step, tfa_code is the necessary one-time code for the 2fa algorithm to work. The system returns a valid JWT and a refresh token with the user's role encoded.

Notice, that this intermediate step with an access token (rather than a JWT), prevents the user from being erroneously authenticated by a microservice, where JWTs are verified with the JWT's public key, but the service has an "all but <role>" filter. In such a case, a "pre_auth" role is also passable.

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

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


Where we use grant_type=refresh_token and the actual refresh token, obtained in the previous steps.

Under the hood, our system looks as follows (Fig 2).

The Workflow of Our Security Server

Fig 2. The workflow of our security server.

For an incoming token request, the system first verifies the client:secret pair in the system's Basic Authentication Filter, which in turn calls the Client and Secret Authentication manager. If positive, the request comes to the authorization endpoint /oauth/token.Then the system calls either a Username and Password verifier, or a 2fa verifier, or a refresh token verifier depending on the request type; these verifiers are used in the appropriate token granters to actually issue the JWTs. In turn, the Username and Password verifier requires a Username and Password authentication manager to do its job. Let's see how to configure these managers.

The Authentication Managers

The Client and Secret Authentication manager is configured automatically by the Spring Authorization server. In Part 2 of this post, I demonstrate how this works in detail. For now, we only need to set a data source for client:secret pairs (see the full codefor details).
Java

@Configuration
@EnableAuthorizationServer
public class AuthorizationConfig extends AuthorizationServerConfigurerAdapter {

    @Autowired
    @Qualifier("dataSource")
    private DataSource dataSource;
    {.....}
    @Override
    public void configure(ClientDetailsServiceConfigurer clients) throws Exception {
        clients.jdbc(dataSource);
    }
    {.....}
}


Next, the Username and Password Authentication Manager is created the following way:

@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();
    }
}


Here, we first get userDetailsService() that verifies Username and Password by means of AccountServiceImpl(). Then we feed this userDetailsService() to configure(AuthenticationManagerBuilder auth) to make the service available for authenticationManagerBean(). It is this authentication manager, that gets autowired into AuthorizationConfig.

As I demonstrate in Part 2, @EnableWebSecurity produces a filter chain out of every descendant of the WebSecurityConfigurerAdapter in the authorization service and the SecurityConfig is no exception. However, we don't need this particular filter chain for our application. We only use the provided infrastructure to create the Username and Password authentication manager. Let's see how to program the token granters.

The Token Granters

We need to create necessary auxiliary classes for the token granters to function and then to provide the granters to the token endpoint:

Java
@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);
    }
}


Here, we set the private key to sign JWTs in RefreshTokenConverter ; this token converter is needed to decode refresh tokens. Next, we set another token converter accessTokenConverter() with the same private key for the TokenStore to sign JWTs. Then, we feed the TokenStore to the TokenGranter. Finally, the TokenGranter is fed to the endpoint configurer: configure(AuthorizationServerEndpointsConfigurer endpoints).

Every token granter must implement a grant(String grantType, TokenRequest tokenRequest) method. The Password Token Granter's grant(...) method is implemented as follows:

Java
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);
            }
 }


Here, we first extract the username:password and client (called clientId here, from the client:secret pair) from the tokenRequest. Then, the system calls the clientDetailService to load the client details from this client and validates the grant type. Next, the authentication manager authenticates the username:password pair. Then the system calls the tfaService to check if 2fa is enabled for this username. If positive, the system calls the tokenService to create a OAuth2Access token with a "pre_auth" role encoded and returns the token. If negative, the system calls the jwtService (that is the tokenService from AuthorizationConfig) to create a JWT, and then returns the token.

The TFAToken Granter's grant() method is implemented as follows:

Java
    @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());
    }


It's a long one, so the details are omitted for brevity (see the code). This token granter works differently from the previous one. Here we override the parent's methods to call the tfaService to verify the 2fa code. If positive, the system checks the user and the client credentials again to return a JWT.

The mentioned TfaService is:

Java
 @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;
    }


The service checks if the user has his/her 2fa enabled, and if the provided code matches the one obtained from the user's secret by the Google Authenticator. Both 2fa enabled and the user's secret are retrieved from the accountRepository.

Finally, let's take a look at the Refresh Token Granter:

Java
 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;
    }


This one is straightforward. The system extracts the refresh token from the request. Then the RefreshConverter (the jwtConverter here) decodes the refresh token. Finally, the system extracts OAuth2Authentication auth and creates a JWT from the auth.

I would like to remark that this token granter is implemented differently from the granter in the original post. There, on the first 2fa step, the user (with 2fa enabled) sends a request with the client:secret, grant type "password", username:password. The system returns a "mfa" (multi-factor authorization) access token with a "pre_auth" role encoded. On the second 2fa step, the user sends back a request with the client:secret, grant type "mfa", the "mfa" access token, and the 2fa code. If verified, the system returns an access token with the user's role encoded.

Our system is modified to return a JWT instead of an access token. Also, on the first step of the 2fa process, our system returns an access token so that it would be impossible to erroneously authorize a "pre_auth" JWT as an "all but user's role" token. Finally, this system does 2fa authentication without a computationally expensive "2fa_required" exception on the first 2fa step.

Results

To test the 2fa functionality, we use 3 container tests, one for every request type. For brevity, I demonstrate how to test the second scenario when a user first provides his/her username:password, then the access token, and the necessary 2fa code.

Java
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"));


Here the curl call for the second scenario is emulated. The tfaService.verify(String username, int code) is stubbed to return true for every code, so that the test runs automatically. This test passes. See the code for the other two tests.

Conclusions

In this post, I demonstrated how to configure a Spring Authorization server to enable 2fa functionality. For this, we need to program 3 token granters and 2 authentication managers, where for 1 of the managers we need only to set a client details data source. Hope to see you in Part 2, where I demonstrate how the Spring Authorization server runs all these under the hood.

Acknowledgments

I would like to thank my mentor Sergey Suchok for his help on this paper.

authentication Spring Framework security Spring Boot JWT (JSON Web Token)

Opinions expressed by DZone contributors are their own.

Related

  • Spring OAuth Server: Token Claim Customization
  • Authentication With Remote LDAP Server in Spring Web MVC
  • Authentication with Spring Boot and Spring Security — JWT and Postgres
  • Develop a Secure CRUD Application Using Angular and Spring Boot

Partner Resources

×

Comments
Oops! Something Went Wrong

The likes didn't load as expected. Please refresh the page and try again.

ABOUT US

  • About DZone
  • Support and feedback
  • Community research
  • Sitemap

ADVERTISE

  • Advertise with DZone

CONTRIBUTE ON DZONE

  • Article Submission Guidelines
  • Become a Contributor
  • Core Program
  • Visit the Writers' Zone

LEGAL

  • Terms of Service
  • Privacy Policy

CONTACT US

  • 3343 Perimeter Hill Drive
  • Suite 100
  • Nashville, TN 37211
  • support@dzone.com

Let's be friends:

Likes
There are no likes...yet! 👀
Be the first to like this post!
It looks like you're not logged in.
Sign in to see who liked this post!