One Time Token With Jwt

How we can use One Time Token with JWT in spring boot security

We often use JWT for the security in spring application. Since by default, one-time token only work with HTML pages.

So here is the example of how we can use it with the rest APIs and the JWT token.

  • We will be securing our application with JWT token, for that, we will use Ouath2ResourceServer.

  • We will be use basic auth for login and issue jwt.

  • We will create a separate endpoint for generating token and validating them, and after successful validation a jwt token is issues successfully.

Classes Needed

  1. JDBCTokenGeneratorService

  2. JwtTokenService

  3. OneTimeTokenSuccessHandler

  4. SecurityConfiguration & RestController

JWT Token Generation

  • For jwt token generation spring-boot-starter-oauth2-resource-server is being used.

  • It internally used nimbus jose jwt jar.

  • JwtEncoder and JwtDecoder beans to jwt security along with public and private keys.

RSA Keys generation

  • To create a public and private key, we will use OpenSSL.

  • It will generate public and private pem file that we will store at /src/main/resources/cers .

# create rsa key pair
openssl genrsa -out keypair.pem 2048

# extract public key
openssl rsa -in keypair.pem -pubout -out public.pem

# create private key in PKCS#8 format
openssl pkcs8 -topk8 -inform PEM -outform PEM -nocrypt -in keypair.pem -out private.pem
  • After creation of keys, delete keypair.pem file.

  • Define path in properties file and create record to hold them.

rsa.public-key=classpath:certs/public.pem
rsa.private-key=classpath:certs/private.pem
@Profile("rest")
@ConfigurationProperties(prefix = "rsa") 
public record RsaKeyProperties(RSAPublicKey publicKey, RSAPrivateKey privateKey) {

}

To bind the properties, we need to tell spring by adding this in any configuration or main class.

@EnableConfigurationProperties(RsaKeyProperties.class)

JWT encoder and decoder bean

In security configuration class, define jwt encode and decoder bean

private final RsaKeyProperties rsaKeyProperties;

// custom jwt decoder with rsa public key to decode incoming jwt token
@Bean
public JwtDecoder jwtDecoder(){
    return NimbusJwtDecoder.withPublicKey(rsaKeyProperties.publicKey()).build();
}

// custom jwt encoder to encode the tokens generated by ouath2, encodes it with both same public and private keys that we will be using in decode
@Bean
JwtEncoder jwtEncoder() {
    JWK jwk = new RSAKey.Builder(rsaKeyProperties.publicKey()).privateKey(rsaKeyProperties.privateKey()).build();
    JWKSource<SecurityContext> jwks = new ImmutableJWKSet<>(new JWKSet(jwk));
    return new NimbusJwtEncoder(jwks);
}

JWT Token Service

@Service
@RequiredArgsConstructor
public class TokenService {

    private final JwtEncoder jwtEncoder;

    /**
     * Method to resolve token, need to pass authentication object,
     * It will generate token with user authorities with 1 hour expiry
     * @param authentication
     * @return
     */
    public String resolveToken(Authentication authentication) {
        Instant issuedAt = Instant.now();

        var authorities = authentication.getAuthorities().stream().map(GrantedAuthority::getAuthority).toList();

        var claims = JwtClaimsSet.builder().issuer("self").issuedAt(issuedAt).expiresAt(issuedAt.plus(1, ChronoUnit.HOURS))
                .subject(authentication.getName()).claim("scope",authorities).build();

        return this.jwtEncoder.encode(JwtEncoderParameters.from(claims)).getTokenValue();
    }

}

JDBCOneTimeTokenService

This class is responsible for generating token and validates them. It uses OneTimeToken entity to store token in database and deleted them after use.

This service is copy of JdbcOneTimeTokenService defined in package org.springframework.security.authentication.ott in spring security.

We also need to schedule a cron to cleanup expired tokens.

package com.app.token.rest;

import com.app.token.repository.OneTimeTokenRepository;
import lombok.NonNull;
import lombok.RequiredArgsConstructor;
import lombok.extern.log4j.Log4j2;
import org.springframework.context.annotation.Profile;
import org.springframework.security.authentication.ott.GenerateOneTimeTokenRequest;
import org.springframework.security.authentication.ott.OneTimeToken;
import org.springframework.security.authentication.ott.OneTimeTokenAuthenticationToken;
import org.springframework.security.authentication.ott.OneTimeTokenService;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.CollectionUtils;

import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.util.List;
import java.util.UUID;

/**
 *
 * A implementation of  {@link OneTimeToken} that used to for token persistence.
 *  It is same as JdbcOneTimeTokenService just to increase token expiry time
 *
 */

@Service
@Profile("rest")
@Log4j2
@RequiredArgsConstructor
public class JDBCTokenGeneratorService implements OneTimeTokenService {

    private final OneTimeTokenRepository oneTimeTokenRepository;

    private final Clock clock = Clock.systemUTC();

    @Transactional
    @Override
    @org.springframework.lang.NonNull
    public OneTimeToken generate(@NonNull GenerateOneTimeTokenRequest request) {
        String token = UUID.randomUUID().toString();
        int expiryInMinutes = 15;
        Instant fiveMinutesFromNow = this.clock.instant().plus(Duration.ofMinutes(expiryInMinutes));
        var oneTimeToken = new com.app.token.entity.OneTimeToken(token, request.getUsername(), fiveMinutesFromNow);
        oneTimeToken = this.oneTimeTokenRepository.save(oneTimeToken);
        return oneTimeToken;
    }


    @Override
    public OneTimeToken consume(@NonNull OneTimeTokenAuthenticationToken authenticationToken) {

        List<com.app.token.entity.OneTimeToken> tokens = this.oneTimeTokenRepository.findByTokenValue(authenticationToken.getTokenValue());
        if (CollectionUtils.isEmpty(tokens)) {
            return null;
        }
        com.app.token.entity.OneTimeToken token = tokens.getFirst();
        this.oneTimeTokenRepository.deleteById(token.getId());

        if (isExpired(token)) {
            return null;
        }

        return token;
    }

    private boolean isExpired(OneTimeToken ott) {
        return this.clock.instant().isAfter(ott.getExpiresAt());
    }

}

SecurityConfiguration

This security configuration has to separate filter chain methods.

One is required to do login with username and password, that only executes when request comes from url as /token

Another one is used for all other application security and ott.

package com.app.token.rest;

import com.app.token.rest.jwt.RsaKeyProperties;
import com.app.token.service.CustomUserDetailsService;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.nimbusds.jose.Algorithm;
import com.nimbusds.jose.jwk.JWK;
import com.nimbusds.jose.jwk.JWKSet;
import com.nimbusds.jose.jwk.RSAKey;
import com.nimbusds.jose.jwk.source.ImmutableJWKSet;
import com.nimbusds.jose.jwk.source.JWKSource;
import com.nimbusds.jose.proc.SecurityContext;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import lombok.extern.log4j.Log4j2;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Profile;
import org.springframework.core.Ordered;
import org.springframework.core.annotation.Order;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.jdbc.core.JdbcOperations;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.ProviderManager;
import org.springframework.security.authentication.dao.DaoAuthenticationProvider;
import org.springframework.security.authentication.ott.OneTimeTokenService;
import org.springframework.security.config.Customizer;
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.configurers.AbstractHttpConfigurer;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.oauth2.jose.jws.SignatureAlgorithm;
import org.springframework.security.oauth2.jwt.JwtDecoder;
import org.springframework.security.oauth2.jwt.JwtEncoder;
import org.springframework.security.oauth2.jwt.NimbusJwtDecoder;
import org.springframework.security.oauth2.jwt.NimbusJwtEncoder;
import org.springframework.security.oauth2.server.resource.web.BearerTokenAuthenticationEntryPoint;
import org.springframework.security.oauth2.server.resource.web.access.BearerTokenAccessDeniedHandler;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.access.AccessDeniedHandler;
import org.springframework.security.web.authentication.AuthenticationFailureHandler;
import org.springframework.security.web.authentication.AuthenticationSuccessHandler;
import org.springframework.security.web.authentication.logout.LogoutSuccessHandler;
import org.springframework.security.web.util.matcher.AntPathRequestMatcher;

import java.io.IOException;
import java.security.AlgorithmConstraints;
import java.util.Map;

/**
 * @Author saurabh vaish
 * @Date 02-02-2025
 */
@Profile("rest")
@Configuration
@EnableWebSecurity
@Log4j2
@RequiredArgsConstructor
public class SecurityConfig {

    private final JDBCTokenGeneratorService jdbcTokenGeneratorService;

    private final String [] permitUrl = {"/error","/exp","/public","/ott/sent","/login/ott","/ott/generate/**","/api/rest/login/ott/consume/**"};

    private final RsaKeyProperties rsaKeyProperties;

    @Bean
    public BCryptPasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }

    // this filter chain will be used to all other things except /token request.
    // it will work all the request from token generation to ott
    @Bean
    public SecurityFilterChain configure(HttpSecurity security) throws Exception {

        return security.authorizeHttpRequests(request ->
                        request
                                .requestMatchers(permitUrl).permitAll()
                                .anyRequest().authenticated()
                )
                .httpBasic(AbstractHttpConfigurer::disable)
                .formLogin(AbstractHttpConfigurer::disable)
                .csrf(AbstractHttpConfigurer::disable)
                .cors(AbstractHttpConfigurer::disable)
                .oauth2ResourceServer(oauth->oauth.jwt(Customizer.withDefaults()))
                .sessionManagement(sess->sess.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
                .exceptionHandling(exceptionHandlingSpec ->new CustomAccessDeniedHandler())
//                .logout(logoutSpec -> logoutSpec.getLogoutSuccessHandler().onLogoutSuccess())
                .oneTimeTokenLogin(ott->
                        ott
                                // default is fine, uses SimpleUrlAuthenticationFailureHandler , that checks token and prepare authentication object
                                // by default only gets username but we can configure Authentication Manger and UserDetailsService to get more details
//                                .authenticationProvider() // with different or custom authentication provider for ott verification

                                // url that specifies how token will get generated with , default one time token service - [InMemory or Jdbc]
                                .tokenGeneratingUrl("/ott/generate") 

                                // specifies the url that process login request, it must be same as the token generated url
                                .loginProcessingUrl("/api/rest/login/ott/consume") 

                                // taking jdbc token service instead of in memory
                                .tokenService(jbdcTokenService()) // used to specify TokenGenerationService - InMemory Or JdbcTokenService , default is {@link InMemoryOneTimeTokenService} with default expiry of 5 minutes

                                // default login page for ott, can be changed to custom one but need to handle csrf
                                // commenting this page as its not needed in rest api, must disable showDefaultSubmitPage
//                                .defaultSubmitPageUrl("/api/rest/login/ott/consume") // token submit page , default is "/login/ott" can be changed to custom one
                                .showDefaultSubmitPage(false)  // sets the value to show submit page or not
//                                .tokenGenerationSuccessHandler()

                                .authenticationFailureHandler(failureHandler()) // specifies the things when token is not valid, defaults to /login?error - SimpleUrlAuthenticationFailureHandler
                                // configures the success handler overrides from default that redirect to previous page, it always redirect to /home
                                .authenticationSuccessHandler(successHandler()) // specifies the success handler - default SavedRequestAwareAuthenticationSuccessHandler
                        )
                .logout(logout->logout.logoutUrl("/logout").logoutSuccessHandler(logoutSuccessHandler()))
                .build();

    }

    /***
     * security filer just for login, other req will be handled by above filter chain
     * it will be using basic auth , once login is done , req will be redirected to {@link RestLoginController}/{token} endpoint .
     * Where we will generate token
     *
     * @param http
     * @return
     * @throws Exception
     */
    @Order(Ordered.HIGHEST_PRECEDENCE)
    @Bean
    SecurityFilterChain tokenSecurityFilterChain(HttpSecurity http) throws Exception {
        return http.securityMatcher("/api/rest/token")
                .authorizeHttpRequests(req->req.anyRequest().authenticated())
                .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
                .csrf(AbstractHttpConfigurer::disable)
                .exceptionHandling(ex -> {
                    ex.authenticationEntryPoint(new BearerTokenAuthenticationEntryPoint());
                    ex.accessDeniedHandler(new BearerTokenAccessDeniedHandler());
                })
                .httpBasic(Customizer.withDefaults())
                .build();
    }

    // dao authentication manager
    @Bean
    public AuthenticationManager authenticationManager(CustomUserDetailsService customUserDetailsService) throws Exception {
        var provider = new DaoAuthenticationProvider();
        provider.setPasswordEncoder(passwordEncoder());
        provider.setUserDetailsService(customUserDetailsService);

        return new ProviderManager(provider);

    }
    
    // custom one time token generator service
    private OneTimeTokenService jbdcTokenService() {
//        return new JdbcOneTimeTokenService(jdbcOperations); // default JdbcOneTimeTokenService with default expiry of 5 minutes
        return jdbcTokenGeneratorService; // custom OneTimeTokenService with default expiry of 15 minutes
    }

    // ott success handler
    private AuthenticationSuccessHandler successHandler() {
//        return new SimpleUrlAuthenticationSuccessHandler("/home");
        return new RestOneTimeTokenAuthenticationSucessHandler();
    }

    private AuthenticationFailureHandler failureHandler(){
        return (request,response,exception)->{
            response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
            response.setContentType("application/json");
            response.setCharacterEncoding("UTF-8");

            var map = Map.of("Unauthorized", exception.getMessage());
            response.getWriter().write(new ObjectMapper().writeValueAsString(map));
        };
    }


    // custom jwt decoder with rsa public key to decode incoming jwt token
    @Bean
    public JwtDecoder jwtDecoder(){
        return NimbusJwtDecoder.withPublicKey(rsaKeyProperties.publicKey()).build();
    }

    // custom jwt encoder to encode the tokens generated by ouath2, encodes it with both same public and private keys that we will be using in decode
    @Bean
    JwtEncoder jwtEncoder() {
        JWK jwk = new RSAKey.Builder(rsaKeyProperties.publicKey()).privateKey(rsaKeyProperties.privateKey()).build();
        JWKSource<SecurityContext> jwks = new ImmutableJWKSet<>(new JWKSet(jwk));
        return new NimbusJwtEncoder(jwks);
    }

    private LogoutSuccessHandler logoutSuccessHandler(){
        return (request, response, authentication) ->{
            response.setStatus(HttpServletResponse.SC_OK);
            response.setContentType(MediaType.APPLICATION_JSON_VALUE);
            response.setCharacterEncoding("UTF-8");
            response.getWriter().write(new ObjectMapper().writeValueAsString("Logout successfully"));
        };
    }

}

class CustomAccessDeniedHandler implements AccessDeniedHandler {

    @Override
    public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException {
        response.setStatus(HttpStatus.FORBIDDEN.value());
        response.setHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE);
        String responseBody = "{\"error\": \"" + accessDeniedException.getLocalizedMessage() + "\"}";
        response.getWriter().write(responseBody);
    }
}

OneTimeTokenSuccessHandler

This class will verify user , get email and send generated token with mail or sms

@Component
@Log4j2
@RequiredArgsConstructor
public class OneTimeTokenSuccessHandler implements OneTimeTokenGenerationSuccessHandler {

    private final EmailService emailService;

    private final CustomUserDetailsService userDetailsService;


    @Override
    public void handle(HttpServletRequest request, HttpServletResponse response, OneTimeToken oneTimeToken) throws IOException, ServletException {

        var email = "";
        try{
            email = checkAndGetEmail(oneTimeToken.getUsername());
        }catch (Exception e){
            log.error("Exception getting email for user: {}", oneTimeToken.getUsername());
            sendJsonResponse(response);
            return;
        }

        // creates a logging processing url 
        UriComponentsBuilder builder = UriComponentsBuilder.fromUriString(UrlUtils.buildFullRequestUrl(request))
                .replacePath(request.getContextPath())
                .replaceQuery(null)
                .fragment(null)
                .path("/api/rest/login/ott/consume")
                .queryParam("token", oneTimeToken.getTokenValue());

        String tokenLink = builder.toUriString();

        System.out.println("Token Link: " + tokenLink );

        var body = """
                Hii %s, Below you will find your secure link to login !
                %s""".formatted(oneTimeToken.getUsername(),magicLink);

        try {
            var sendTo = oneTimeToken.getUsername();
            log.info("Sending One Time Token to username: {}", sendTo);

           emailService.sendMail(email, "One Time Token Login", body,false);
        } catch (Exception e) {
            e.printStackTrace();
            log.error("Exception sending email: {}", e.getMessage());
            sendJsonResponse(response);
        }
    }

    private String checkAndGetEmail(String username) {
        log.info("Retrieving email for user: {}", username);

        var user = (AppUser)userDetailsService.loadUserByUsername(username);
        return user.getEmail();
    }


    public void sendJsonResponse(HttpServletResponse response) throws IOException {
        response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
        response.setContentType("application/json");
        response.setCharacterEncoding("UTF-8");

        var map = Map.of("Unauthorized", "Session Timeout");
        response.getWriter().write(new ObjectMapper().writeValueAsString(map));
    }

}

AuthenticationSuccessHandler

public class RestAuthenticationSuccessHandler implements AuthenticationSuccessHandler {

    @Override
    public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
        clearAuthenticationAttributes(request);
        response.setStatus(HttpServletResponse.SC_OK);
    }

    protected final void clearAuthenticationAttributes(HttpServletRequest request) {
        HttpSession session = request.getSession(false);
        if (session != null) {
            session.removeAttribute(WebAttributes.AUTHENTICATION_EXCEPTION);
        }
    }
}

RestController

@RestController
@RequestMapping("/api/rest")
@Log4j2
@RequiredArgsConstructor
public class RestLoginController {

    private final TokenService tokenService;

    @PostMapping("/login/ott/consume")
    public ResponseEntity<String> consumeOtt(Authentication principal){
        System.out.println("principal = " + principal.getName());
        return ResponseEntity.ok("Successfully logged in , token ::" + tokenService.resolveToken(principal));
    }

    @PostMapping("/token")
    public ResponseEntity<String> generateToken(Authentication authentication){
        log.debug("Token requested for user: '{}'", authentication.getName());
        String token = tokenService.resolveToken(authentication);
        log.debug("Token granted: {}", token);
        return ResponseEntity.ok(token);
    }


    @PostMapping("/validate")
    public void validateToken(String token){
        log.info("request to ");
    }

    @GetMapping("/secure/api")
    public ResponseEntity<String> secureApi(Authentication principal){
        return ResponseEntity.ok("secure api = " + principal.getName());
    }

}

Last updated