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.
AppUser-
Entity that stores the user basic information.
OneTimeToken-
Entity that stores one time token information, like name, expiration etc.
CustomUserDetailsService-
UserDetailsService implementation that fetches users from that database and returns a UserDetails (I) which has the implementation AppUser.
SecurityConfig-
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.
JDBCTokenGeneratorService-
This is the custom token generation service, which is responsible for generating tokens and validating them.
It is inspired from original JDBCTokenGeneratorService, just the expiry time has been increased from 5 to 15 minutes.
It also has the cron jobs that clears token automatically.
OneTimeTokenSuccessHandler-
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