One Time Token with custom configuration

Here is the example of the one time token in spring security with custom configuration.

This is the custom one time token implementation with some own configurations.

  1. AppUser-

    1. Entity that stores the user basic information.

  2. OneTimeToken-

    1. Entity that stores one time token information, like name, expiration etc.

  3. CustomUserDetailsService-

    1. UserDetailsService implementation that fetches users from that database and returns a UserDetails (I) which has the implementation AppUser.

  4. SecurityConfig-

    1. The default submit page url and token generation urls are the same, but after token validation we are directing to /login/ott/success that returns the secure page.

  5. JDBCTokenGeneratorService-

    1. This is the custom token generation service, which is responsible for generating tokens and validating them.

    2. It is inspired from original JDBCTokenGeneratorService, just the expiry time has been increased from 5 to 15 minutes.

    3. It also has the cron jobs that clears token automatically.

  6. OneTimeTokenSuccessHandler-

    1. In this class, after token generation token are being sent to username, for that first user is being fetched from the database using a custom user details service

pom.xml

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
</dependency>

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-security</artifactId>
</dependency>

<dependency>
    <groupId>gg.jte</groupId>
    <artifactId>jte</artifactId>
    <version>3.1.12</version>
</dependency>
<dependency>
    <groupId>gg.jte</groupId>
    <artifactId>jte-spring-boot-starter-3</artifactId>
    <version>3.1.12</version>
</dependency>

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-jdbc</artifactId>
</dependency>

<dependency>
    <groupId>org.postgresql</groupId>
    <artifactId>postgresql</artifactId>
    <version>42.7.5</version>
</dependency>

application.properties

spring.application.name=spring-one-time-token
logging.level.root=info
logging.level.org.springframework.security=DEBUG
logging.level.gg.jte=DEBUG


spring.sendgrid.api-key=API_KEY
spring.sendgrid.from-email=EMAIL

spring.profiles.active=rest

spring.datasource.url=jdbc:postgresql://localhost:5432/DB_NAME
spring.datasource.username=USERNAME
spring.datasource.password=PASSWORD

spring.jpa.show-sql=true
spring.jpa.hibernate.ddl-auto=update
spring.jpa.open-in-view=false

gg.jte.developmentMode=true
gg.jte.templateLocation=src/main/jte

AppUser entity

@Getter
@Setter
@NoArgsConstructor
@Entity
@Table(name = "app_users",indexes = {
        @Index(name = "idx_username", columnList = "username", unique = true),
        @Index(name = "idx_email", columnList = "email", unique = true)
})
public class AppUser implements UserDetails {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String firstName;

    private String lastName;

    @Column(nullable = false, unique = true)
    private String username;

    @Column(nullable = false)
    private String password;

    @Column(unique = true)
    private String email;

    @Column(nullable = false)
    private String role;

    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        return List.of(new SimpleGrantedAuthority("ROLE_" + this.role));
    }

    @Override
    public String getUsername() {
        return this.username;
    }

    public String getFullName() {
        return this.firstName + " " + this.lastName;
    }
}

OneTimeToken entity

@Getter
@Table(name = "one_time_tokens")
public class OneTimeToken extends DefaultOneTimeToken {

    @Column(name = "username")
    private String userName;

    @Column(name = "token_value")
    private String tokenValue;

    @Column(name = "expires_at")
    private Instant expiresAt;

    public OneTimeToken(String token, String username, Instant expireAt) {
        super(token, username, expireAt);
    }
}

Security Configuration

@Profile("custom")
@Configuration
@EnableWebSecurity
@Log4j2
@RequiredArgsConstructor
public class SecurityConfig {

    private final JdbcOperations jdbcOperations;

    private final String [] permitUrl = {"/error","/exp","/public","/ott/sent","/login/ott"};

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

    @Bean
    public SecurityFilterChain configure(HttpSecurity security) throws Exception {

        return security.authorizeHttpRequests(request ->
                        request
                                .requestMatchers(permitUrl).permitAll()
                                .anyRequest().authenticated()
                )
                .formLogin(Customizer.withDefaults()) // default form login
                .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

                                // default login page for ott, can be changed to custom one but need to handle csrf
                                .defaultSubmitPageUrl("/login/ott") // token submit page , default is "/login/ott" can be changed to custom one
//                                .showDefaultSubmitPage(true)  // sets the value to show submit page or not

                                // default is fine , no need to change but can be changed to custom one
                                .tokenGeneratingUrl("/ott/generate") // url that specifies how token will get generated with , default one time token service - [InMemory or Jdbc]

                                .loginProcessingUrl("/login/ott/success") // specifies the url that process login request

                                // 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
//                                .authenticationFailureHandler() // 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
                        )
//                .csrf(AbstractHttpConfigurer::disable) // csrf needed as spring uses form login for ott
                .build();

    }

    // authentication manager that uses DaoAuthenticationProvider to get user info from database
    @Bean
    public AuthenticationManager authenticationManager(CustomUserDetailsService customUserDetailsService) throws Exception {
        var provider = new DaoAuthenticationProvider();
        provider.setPasswordEncoder(passwordEncoder());
        provider.setUserDetailsService(customUserDetailsService);

        return new ProviderManager(provider);

    }

    // custom token generation service 
    private OneTimeTokenService jbdcTokenService() {
//        return new JdbcOneTimeTokenService(jdbcOperations); // default JdbcOneTimeTokenService with default expiry of 5 minutes
        return new JDBCTokenGeneratorService(jdbcOperations); // custom OneTimeTokenService with default expiry of 15 minutes
    }

    // authentication success handler that redirects to home page after login
    private AuthenticationSuccessHandler successHandler() {
        return new SimpleUrlAuthenticationSuccessHandler("/home");
    }

}

OneTimeTokenSuccessHandler

@Profile("custom")
@Component
@Log4j2
@RequiredArgsConstructor
public class OneTimeTokenSuccessHandler implements OneTimeTokenGenerationSuccessHandler {

    // ott success handler that uses redirect to /ott/sent page
    private final OneTimeTokenGenerationSuccessHandler redirectHandler = new RedirectOneTimeTokenGenerationSuccessHandler("/ott/sent");
    private final AuthenticationFailureHandler failureHandler = new SimpleUrlAuthenticationFailureHandler("/exp?message=Username not found");
    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()); // validated and get email
        }catch (Exception e){
            log.error("Exception getting email for user: {}", oneTimeToken.getUsername());
            failureHandler.onAuthenticationFailure(request, response, new UsernameNotFoundException("Exception getting email for user"));
            return;
        }

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

        String tokenLink = builder.toUriString();

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

        var body = """
                Hello, %s! Below you will find your secure link to login !
                %s""".formatted(oneTimeToken.getUserName(),tokenLink);

        try {
            var sendTo = oneTimeToken.getUsername();
            log.info("Sending One Time Token to username: {}", sendTo);
            
            // sending mail to user
            emailService.sendMail(email, "One Time Token Login", body,false);
        } catch (Exception e) {
            e.printStackTrace();
            log.error("Exception sending email: {}", e.getMessage());
        }
        
        // redirects to token sent page
        this.redirectHandler.handle(request, response, oneTimeToken);
    }

    // getting user by user name from user details service
    private String checkAndGetEmail(String username) {
        log.info("Retrieving email for user: {}", username);

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

UserDetailsService

@Service
@RequiredArgsConstructor
public class CustomUserDetailsService implements UserDetailsService {

    private final AppUserRepository appUserRepository;

    // fetches user from database and returns spring security UserDetails
    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        return appUserRepository.findByUsernameIgnoreCase(username)
                .orElseThrow(() -> new UsernameNotFoundException("User not found"));
    }
}

rest of the details like controller and html pages are the same as previous one.

Last updated