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