Spring Security: Authentication using JWT

Spring Security: Authentication using JWT

Today, I will be guiding you on how to use Spring Security to authenticate users using JWT. Although there are many tutorials available online and the official Spring documentation has a section on this topic, I have written my version of the guideline after conducting research and experiencing some difficulties. I hope that it will be helpful for someone out there.

Note that, the version of Spring Security I’m mentioning throughout this blog is 6.2.1

Okay, let’s get started.

Here is the diagram represents the high-level architecture of Spring Security

I. Spring Security Architecture High-Level: Spring Security.drawio
  • Filter Chain: Spring Security’s Servlet support is based on Servlet Filters. When a client makes a request, the Servlet container creates a FilterChain which contains some Filters which the request has to go through. the request will be processed with those filters one by one in order.

  • Delegating Filter Proxy: A proxy for a Filter inside the FilterChain mentioned above since the Servlet container knows nothing about the ApplicationContext. This proxy allows the Servlet container to commute to ApplicationContext inside it.

  • FilterChainProxy: A Spring Bean Filter provided by Spring Security wrapped in a Delegating Filter Proxy which is responsible for managing security-related filters and enforcing security policies in a Spring Security-enabled application. It acts as the entry point for all incoming requests and delegates the request processing to the appropriate SecurityFilterChain

  • SecurityFilterChain: represents a chain of security Filter configured for a specific set of URL patterns

  • ExceptionTranslationFilter handles exceptions related to authentication and authorization in a Spring Security-enabled application, it’s automatically inserted into the SecurityFilterChain Note that this Filter only catches AuthenticationException and

    AccessDeniedException If other exceptions are thrown, this Filter does nothing.

    • Authentication Entry Point: determine the appropriate action to take when AuthenticationException occurs
    • AccessDeniedException: handle AccessDeniedException

Note that, I assume that you guys know about JWT beforehand, so I won’t recall the definition, or use cases of JWT in this blog.

First, head over to start.spring.io and create a new project with the following settings:

  • Build Tool: Gradle - Groovy
  • Language: Java
  • Packaging: Jar
  • Java Version: 17

Next, add the following dependencies:

java
dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
    implementation 'org.springframework.boot:spring-boot-starter-web'
    implementation 'org.springframework.boot:spring-boot-starter-security'
    implementation 'io.jsonwebtoken:jjwt-api:0.12.5'

    compileOnly 'org.projectlombok:lombok'

    developmentOnly 'org.springframework.boot:spring-boot-devtools'

    runtimeOnly 'com.h2database:h2'
    runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.12.5'
    runtimeOnly "io.jsonwebtoken:jjwt-jackson:0.12.5"

    annotationProcessor 'org.projectlombok:lombok'

    testImplementation 'org.springframework.boot:spring-boot-starter-test'
}
dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
    implementation 'org.springframework.boot:spring-boot-starter-web'
    implementation 'org.springframework.boot:spring-boot-starter-security'
    implementation 'io.jsonwebtoken:jjwt-api:0.12.5'

    compileOnly 'org.projectlombok:lombok'

    developmentOnly 'org.springframework.boot:spring-boot-devtools'

    runtimeOnly 'com.h2database:h2'
    runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.12.5'
    runtimeOnly "io.jsonwebtoken:jjwt-jackson:0.12.5"

    annotationProcessor 'org.projectlombok:lombok'

    testImplementation 'org.springframework.boot:spring-boot-starter-test'
}

Here is the project structure

2. Setup some base files — figure 1

First, we'll set up some necessary files. Please note that this blog focuses on setting up Spring Security, so other classes will be kept as simple as possible. In real-life applications, you may need to include things such as validation and business logic checks.

  • application.yml

    yaml
    spring:
      datasource:
        url: jdbc:h2:mem:testdb
        username: sa
        password: password
        driver-class-name: org.h2.Driver
        generate-unique-name: false
        name: my-datasource
      h2:
        console:
          enabled: true
      jpa:
        defer-datasource-initialization: true
    
    app:
      jwt:
        base64-secret: ZmQ0ZGI5NjQ0MDQwY2I4MjMxY2Y3ZmI3MjdhN2ZmMjNhODViOTg1ZGE0NTBjMGM4NDA5NzYxMjdjOWMwYWRmZTBlZjlhNGY3ZTg4Y2U3YTE1ODVkZDU5Y2Y3OGYwZWE1NzUzNWQ2YjFjZDc0NGMxZWU2MmQ3MjY1NzJmNTE0MzI=
        ttl:
          access-token: 3600000
          refresh-token: 86400000
    logging:
      level:
        root: trace
    spring:
      datasource:
        url: jdbc:h2:mem:testdb
        username: sa
        password: password
        driver-class-name: org.h2.Driver
        generate-unique-name: false
        name: my-datasource
      h2:
        console:
          enabled: true
      jpa:
        defer-datasource-initialization: true
    
    app:
      jwt:
        base64-secret: ZmQ0ZGI5NjQ0MDQwY2I4MjMxY2Y3ZmI3MjdhN2ZmMjNhODViOTg1ZGE0NTBjMGM4NDA5NzYxMjdjOWMwYWRmZTBlZjlhNGY3ZTg4Y2U3YTE1ODVkZDU5Y2Y3OGYwZWE1NzUzNWQ2YjFjZDc0NGMxZWU2MmQ3MjY1NzJmNTE0MzI=
        ttl:
          access-token: 3600000
          refresh-token: 86400000
    logging:
      level:
        root: trace
  • schema.sql

    sql
    INSERT INTO post(id, user_id, title, content)
    VALUES (1, 1, 'test_blog', 'test_blog_content');
    INSERT INTO post(id, user_id, title, content)
    VALUES (1, 1, 'test_blog', 'test_blog_content');

  • AuthResponse

    java
    @Getter
    @Setter
    @Builder
    @NoArgsConstructor
    @AllArgsConstructor
    public class AuthResponse {
      private String accessToken;
      private String refreshToken;
      private AppUser user;
    }
    @Getter
    @Setter
    @Builder
    @NoArgsConstructor
    @AllArgsConstructor
    public class AuthResponse {
      private String accessToken;
      private String refreshToken;
      private AppUser user;
    }
  • LoginInput

    java
    @Getter
    @Setter
    public class LoginInput {
      private String username;
      private String password;
    }
    @Getter
    @Setter
    public class LoginInput {
      private String username;
      private String password;
    }
  • SignUpInput

    java
    @Getter
    @Setter
    public class SignUpInput {
      private String username;
      private String password;
      private String name;
    }
    @Getter
    @Setter
    public class SignUpInput {
      private String username;
      private String password;
      private String name;
    }

  • AppUser (so we won’t mix with the User entity provided by Spring security)

    java
    @Entity
    @Getter
    @Builder
    @NoArgsConstructor
    @AllArgsConstructor
    public class AppUser {
      @Id
      private Long id;
      private String username;
      private String password;
      private String name;
    }
    @Entity
    @Getter
    @Builder
    @NoArgsConstructor
    @AllArgsConstructor
    public class AppUser {
      @Id
      private Long id;
      private String username;
      private String password;
      private String name;
    }
  • JwtToken

    java
    public record JwtToken(String accessToken, String refreshToken) {
    }
    public record JwtToken(String accessToken, String refreshToken) {
    }
  • Post

    java
    @Entity
    @Getter
    @Builder
    @NoArgsConstructor
    @AllArgsConstructor
    public class Post {
      @Id
      private Long id;
      private String title;
      private String content;
      private String userId;
    }
    @Entity
    @Getter
    @Builder
    @NoArgsConstructor
    @AllArgsConstructor
    public class Post {
      @Id
      private Long id;
      private String title;
      private String content;
      private String userId;
    }

  • UserRepository

    java
    public interface UserRepository extends ListCrudRepository<AppUser, Long> {
      Optional<User> findByUsername(String username);
    }
    public interface UserRepository extends ListCrudRepository<AppUser, Long> {
      Optional<User> findByUsername(String username);
    }
  • PostRepository

    java
    public interface PostRepository extends ListCrudRepository<Post, Long> {
    }
    public interface PostRepository extends ListCrudRepository<Post, Long> {
    }

  • UserService

    java
    @Service
    @RequiredArgsConstructor
    public class UserService {
      private final UserRepository userRepository;
    
      public Optional<AppUser> findByUsername(String username) {
        return userRepository.findByUsername(username);
      }
    
    	public void save(AppUser user) {
        userRepository.save(user);
      }
    }
    @Service
    @RequiredArgsConstructor
    public class UserService {
      private final UserRepository userRepository;
    
      public Optional<AppUser> findByUsername(String username) {
        return userRepository.findByUsername(username);
      }
    
    	public void save(AppUser user) {
        userRepository.save(user);
      }
    }
  • AuthService - Undone

    java
    @Service
    @RequiredArgsConstructor
    public class AuthService {
      private final UserService userService;
      private final AtomicLong userId = new AtomicLong(1);
    
      @Transactional
      public AuthResponse login(LoginInput loginInput) {
    		final Optional<AppUser> userOpt = userService.findByUsername(
            loginInput.getUsername()
        );
        if (userOpt.isEmpty()) {
          throw new RuntimeException("User not found");
        }
    		// TODO: Add logic to generate token here
       
        return AuthResponse.builder()
            .user(userOpt.get())
            .build();
      }
    
      @Transactional
      public AuthResponse signUp(SignUpInput signUpInput) {
        final AppUser user = new AppUser(
            userId.getAndIncrement(),
            signUpInput.getUsername(),
            signUpInput.getPassword(),
            signUpInput.getName()
        );
    	// TODO: Add logic to generate token here
       
        userService.save(user);
        return AuthResponse.builder()
            .user(user)
            .build();
      }
    }
    @Service
    @RequiredArgsConstructor
    public class AuthService {
      private final UserService userService;
      private final AtomicLong userId = new AtomicLong(1);
    
      @Transactional
      public AuthResponse login(LoginInput loginInput) {
    		final Optional<AppUser> userOpt = userService.findByUsername(
            loginInput.getUsername()
        );
        if (userOpt.isEmpty()) {
          throw new RuntimeException("User not found");
        }
    		// TODO: Add logic to generate token here
       
        return AuthResponse.builder()
            .user(userOpt.get())
            .build();
      }
    
      @Transactional
      public AuthResponse signUp(SignUpInput signUpInput) {
        final AppUser user = new AppUser(
            userId.getAndIncrement(),
            signUpInput.getUsername(),
            signUpInput.getPassword(),
            signUpInput.getName()
        );
    	// TODO: Add logic to generate token here
       
        userService.save(user);
        return AuthResponse.builder()
            .user(user)
            .build();
      }
    }

  • AuthController

    java
    @RestController
    @RequestMapping("/api/auth")
    @RequiredArgsConstructor
    public class AuthController {
      private final AuthService authService;
    
      @PostMapping("/login")
      public ResponseEntity<AuthResponse> login(
          @RequestBody LoginInput loginInput
      ) {
        return ResponseEntity.ok(authService.login(loginInput));
      }
    
      @PostMapping("/signup")
      public ResponseEntity<AuthResponse> signup(
          @RequestBody SignUpInput signUpInput
      ) {
        return ResponseEntity.ok(authService.signUp(signUpInput));
      }
    }
    @RestController
    @RequestMapping("/api/auth")
    @RequiredArgsConstructor
    public class AuthController {
      private final AuthService authService;
    
      @PostMapping("/login")
      public ResponseEntity<AuthResponse> login(
          @RequestBody LoginInput loginInput
      ) {
        return ResponseEntity.ok(authService.login(loginInput));
      }
    
      @PostMapping("/signup")
      public ResponseEntity<AuthResponse> signup(
          @RequestBody SignUpInput signUpInput
      ) {
        return ResponseEntity.ok(authService.signUp(signUpInput));
      }
    }

This class is responsible for generating, validating JWT tokens.

  • The code

    java
    @Log4j2
    @Component
    @RequiredArgsConstructor
    public class JwtProvider implements InitializingBean {
      private static final String AUTHORITIES_KEY = "authorities";
    
      @Value("${app.jwt.base64-secret}")
      private String base64Secret;
    
      @Value("${app.jwt.ttl.access-token}")
      private long accessTokenTtl;
    
      @Value("${app.jwt.ttl.refresh-token}")
      private long refreshTokenTtl;
    
      private SecretKey key;
    
      @Override
      public void afterPropertiesSet() {
        byte[] keyBytes = Decoders.BASE64.decode(base64Secret);
        this.key = Keys.hmacShaKeyFor(keyBytes);
      }
    
      public JwtToken createTokens(
          String username
      ) {
        final long now = Instant.now().toEpochMilli();
        final Date accessTokenValidity = new Date(now + this.accessTokenTtl);
        final Date refreshTokenValidity = new Date(now + this.refreshTokenTtl);
        return new JwtToken(
            createTokens(username, accessTokenValidity),
            createTokens(username, refreshTokenValidity)
        );
      }
    
      private String createTokens(
          Authentication authentication,
          Date validity,
          String authorities
      ) {
        return Jwts.builder()
            .subject(authentication.getName())
            .claim(AUTHORITIES_KEY, authorities)
            .signWith(key)
            .expiration(validity)
            .compact();
      }
    
      private String createTokens(
          String username,
          Date validity
      ) {
        return Jwts.builder()
            .subject(username)
            .claim(AUTHORITIES_KEY, Collections.emptyList())
            .signWith(key)
            .expiration(validity)
            .compact();
      }
    
      public Authentication getAuthentication(String token) {
        final Claims claims = Jwts.parser()
            .verifyWith(key)
            .build()
            .parseSignedClaims(token)
            .getPayload();
    
        final Collection<? extends GrantedAuthority> authorities =
            Arrays.stream(
                    claims.get(AUTHORITIES_KEY).toString()
                        .split(",")
                )
                .map(SimpleGrantedAuthority::new)
                .collect(Collectors.toList());
    
    		// Note: This `User` class is imported from 
    		// import org.springframework.security.core.userdetails.User;
        final User principal = new User(claims.getSubject(), "", authorities);
        return new UsernamePasswordAuthenticationToken(principal, token, authorities);
      }
    
      public boolean validateToken(String authToken) {
        try {
          Jwts.parser().verifyWith(key).build().parseSignedClaims(authToken);
          return true;
        } catch (SecurityException | MalformedJwtException e) {
          log.info("Invalid JWT signature.");
          log.trace("Invalid JWT signature trace: %s", e);
        } catch (ExpiredJwtException e) {
          log.info("Expired JWT token.");
          log.trace("Expired JWT token trace: ", e);
        } catch (UnsupportedJwtException e) {
          log.info("Unsupported JWT token.");
          log.trace("UnSupported JWT token trace: %s", e);
        } catch (IllegalArgumentException e) {
          log.info("JWT token compact of handler are invalid.");
          log.trace("JWT token compact of handler are invalid trace: %s", e);
        }
        return false;
      }
    }
    @Log4j2
    @Component
    @RequiredArgsConstructor
    public class JwtProvider implements InitializingBean {
      private static final String AUTHORITIES_KEY = "authorities";
    
      @Value("${app.jwt.base64-secret}")
      private String base64Secret;
    
      @Value("${app.jwt.ttl.access-token}")
      private long accessTokenTtl;
    
      @Value("${app.jwt.ttl.refresh-token}")
      private long refreshTokenTtl;
    
      private SecretKey key;
    
      @Override
      public void afterPropertiesSet() {
        byte[] keyBytes = Decoders.BASE64.decode(base64Secret);
        this.key = Keys.hmacShaKeyFor(keyBytes);
      }
    
      public JwtToken createTokens(
          String username
      ) {
        final long now = Instant.now().toEpochMilli();
        final Date accessTokenValidity = new Date(now + this.accessTokenTtl);
        final Date refreshTokenValidity = new Date(now + this.refreshTokenTtl);
        return new JwtToken(
            createTokens(username, accessTokenValidity),
            createTokens(username, refreshTokenValidity)
        );
      }
    
      private String createTokens(
          Authentication authentication,
          Date validity,
          String authorities
      ) {
        return Jwts.builder()
            .subject(authentication.getName())
            .claim(AUTHORITIES_KEY, authorities)
            .signWith(key)
            .expiration(validity)
            .compact();
      }
    
      private String createTokens(
          String username,
          Date validity
      ) {
        return Jwts.builder()
            .subject(username)
            .claim(AUTHORITIES_KEY, Collections.emptyList())
            .signWith(key)
            .expiration(validity)
            .compact();
      }
    
      public Authentication getAuthentication(String token) {
        final Claims claims = Jwts.parser()
            .verifyWith(key)
            .build()
            .parseSignedClaims(token)
            .getPayload();
    
        final Collection<? extends GrantedAuthority> authorities =
            Arrays.stream(
                    claims.get(AUTHORITIES_KEY).toString()
                        .split(",")
                )
                .map(SimpleGrantedAuthority::new)
                .collect(Collectors.toList());
    
    		// Note: This `User` class is imported from 
    		// import org.springframework.security.core.userdetails.User;
        final User principal = new User(claims.getSubject(), "", authorities);
        return new UsernamePasswordAuthenticationToken(principal, token, authorities);
      }
    
      public boolean validateToken(String authToken) {
        try {
          Jwts.parser().verifyWith(key).build().parseSignedClaims(authToken);
          return true;
        } catch (SecurityException | MalformedJwtException e) {
          log.info("Invalid JWT signature.");
          log.trace("Invalid JWT signature trace: %s", e);
        } catch (ExpiredJwtException e) {
          log.info("Expired JWT token.");
          log.trace("Expired JWT token trace: ", e);
        } catch (UnsupportedJwtException e) {
          log.info("Unsupported JWT token.");
          log.trace("UnSupported JWT token trace: %s", e);
        } catch (IllegalArgumentException e) {
          log.info("JWT token compact of handler are invalid.");
          log.trace("JWT token compact of handler are invalid trace: %s", e);
        }
        return false;
      }
    }

After we create the JwtProvider it’s time to come back and full-fill the AuthService with the missing code for generating tokens

  • AuthService - full

    java
    @Service
    @RequiredArgsConstructor
    public class AuthService {
      private final UserService userService;
      private final JwtProvider jwtProvider;
      private final AtomicLong userId = new AtomicLong(1);
    
      @Transactional
      public AuthResponse login(LoginInput loginInput) {
        final Optional<AppUser> userOpt = userService.findByUsername(
    			loginInput.getUsername()
    		);
        if (userOpt.isEmpty()) {
          throw new RuntimeException("User not found");
        }
        final JwtToken jwtTokens = jwtProvider.createTokens(loginInput.getUsername());
        return AuthResponse.builder()
            .accessToken(jwtTokens.accessToken())
            .refreshToken(jwtTokens.refreshToken())
            .appUser(userOpt.get())
            .build();
      }
    
      @Transactional
      public AuthResponse signUp(SignUpInput signUpInput) {
        final AppUser appUser = new AppUser(
            userId.getAndIncrement(),
            signUpInput.getUsername(),
            signUpInput.getPassword(),
            signUpInput.getName()
        );
        final JwtToken jwtTokens = jwtProvider.createTokens(signUpInput.getUsername());
        userService.save(appUser);
        return AuthResponse.builder()
            .accessToken(jwtTokens.accessToken())
            .refreshToken(jwtTokens.refreshToken())
            .appUser(appUser)
            .build();
      }
    }
    @Service
    @RequiredArgsConstructor
    public class AuthService {
      private final UserService userService;
      private final JwtProvider jwtProvider;
      private final AtomicLong userId = new AtomicLong(1);
    
      @Transactional
      public AuthResponse login(LoginInput loginInput) {
        final Optional<AppUser> userOpt = userService.findByUsername(
    			loginInput.getUsername()
    		);
        if (userOpt.isEmpty()) {
          throw new RuntimeException("User not found");
        }
        final JwtToken jwtTokens = jwtProvider.createTokens(loginInput.getUsername());
        return AuthResponse.builder()
            .accessToken(jwtTokens.accessToken())
            .refreshToken(jwtTokens.refreshToken())
            .appUser(userOpt.get())
            .build();
      }
    
      @Transactional
      public AuthResponse signUp(SignUpInput signUpInput) {
        final AppUser appUser = new AppUser(
            userId.getAndIncrement(),
            signUpInput.getUsername(),
            signUpInput.getPassword(),
            signUpInput.getName()
        );
        final JwtToken jwtTokens = jwtProvider.createTokens(signUpInput.getUsername());
        userService.save(appUser);
        return AuthResponse.builder()
            .accessToken(jwtTokens.accessToken())
            .refreshToken(jwtTokens.refreshToken())
            .appUser(appUser)
            .build();
      }
    }

The SecurityFilter checks the validity of the token in incoming requests.

It examines if the token is present in the request header. If found, it validates the token and sets the authentication info into the SecurityContext.

Typically, the SecurityFilter class implements the Filter interface. However, to ensure the JwtAuthenticationFilter executes only once for each request, we create a class that extends the OncePerRequestFilter instead of implementing the Filter interface.

With OncePerRequestFilter, the security filter's core logic resides in the doFilterInternal function. Conversely, with Filter, the core logic is in the doFilter function.

  • The code

    java
    @Log4j2
    @RequiredArgsConstructor
    public class JwtAuthenticationFilter extends OncePerRequestFilter {
      public static final String AUTHORIZATION_HEADER = "Authorization";
    
      private final JwtProvider jwtProvider;
    
      @Override
      protected void doFilterInternal(
          @NonNull HttpServletRequest request,
          @NonNull HttpServletResponse response,
          @NonNull FilterChain filterChain
      ) throws ServletException, IOException {
        System.out.println("JwtAuthenticationFilter doFilterInternal is called");
        String jwt = resolveToken(request);
        String requestURI = request.getRequestURI();
        if (StringUtils.hasText(jwt)) {
          final boolean isTokenValid = jwtProvider.validateToken(jwt);
          if (isTokenValid) {
            final Authentication authentication = jwtProvider.getAuthentication(jwt);
            SecurityContextHolder.getContext().setAuthentication(authentication);
            log.debug(
                "set Authentication to security context for '{}', uri: {}",
                authentication.getName(), requestURI
            );
          } else {
            log.debug("Invalid token for uri: {}", requestURI);
          }
        }
        filterChain.doFilter(request, response);
      }
    
      private String resolveToken(HttpServletRequest request) {
        String bearerToken = request.getHeader(AUTHORIZATION_HEADER);
        if (StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer ")) {
          return bearerToken.substring(7);
        }
        return null;
      }
    }
    @Log4j2
    @RequiredArgsConstructor
    public class JwtAuthenticationFilter extends OncePerRequestFilter {
      public static final String AUTHORIZATION_HEADER = "Authorization";
    
      private final JwtProvider jwtProvider;
    
      @Override
      protected void doFilterInternal(
          @NonNull HttpServletRequest request,
          @NonNull HttpServletResponse response,
          @NonNull FilterChain filterChain
      ) throws ServletException, IOException {
        System.out.println("JwtAuthenticationFilter doFilterInternal is called");
        String jwt = resolveToken(request);
        String requestURI = request.getRequestURI();
        if (StringUtils.hasText(jwt)) {
          final boolean isTokenValid = jwtProvider.validateToken(jwt);
          if (isTokenValid) {
            final Authentication authentication = jwtProvider.getAuthentication(jwt);
            SecurityContextHolder.getContext().setAuthentication(authentication);
            log.debug(
                "set Authentication to security context for '{}', uri: {}",
                authentication.getName(), requestURI
            );
          } else {
            log.debug("Invalid token for uri: {}", requestURI);
          }
        }
        filterChain.doFilter(request, response);
      }
    
      private String resolveToken(HttpServletRequest request) {
        String bearerToken = request.getHeader(AUTHORIZATION_HEADER);
        if (StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer ")) {
          return bearerToken.substring(7);
        }
        return null;
      }
    }

The Authentication Entry Point to AuthenticationException

In this class, we will response to the client with status code is 401 and the response body is:

json
{
    "error": "Unauthorized",
    "message": "Invalid token"
}
{
    "error": "Unauthorized",
    "message": "Invalid token"
}
  • The code

    java
    @Component
    public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint {
      @Override
      public void commence(
          HttpServletRequest request,
          HttpServletResponse response,
          AuthenticationException authException
      ) throws IOException {
        response.setContentType("application/json");
        response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
        PrintWriter writer = response.getWriter();
        writer.println("{ \"error\": \"Unauthorized\", \"message\": \"Invalid token\"}");
      }
    }
    @Component
    public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint {
      @Override
      public void commence(
          HttpServletRequest request,
          HttpServletResponse response,
          AuthenticationException authException
      ) throws IOException {
        response.setContentType("application/json");
        response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
        PrintWriter writer = response.getWriter();
        writer.println("{ \"error\": \"Unauthorized\", \"message\": \"Invalid token\"}");
      }
    }

The Access Denied Handler

In this class, we only return the status code 403 to the client.

  • The code

    java
    @Component
    public class JwtAccessDeniedHandler implements AccessDeniedHandler {
      @Override
      public void handle(
          HttpServletRequest request,
          HttpServletResponse response,
          AccessDeniedException accessDeniedException
      ) throws IOException {
        response.sendError(
            HttpServletResponse.SC_FORBIDDEN,
            accessDeniedException.getMessage()
        );
      }
    }
    @Component
    public class JwtAccessDeniedHandler implements AccessDeniedHandler {
      @Override
      public void handle(
          HttpServletRequest request,
          HttpServletResponse response,
          AccessDeniedException accessDeniedException
      ) throws IOException {
        response.sendError(
            HttpServletResponse.SC_FORBIDDEN,
            accessDeniedException.getMessage()
        );
      }
    }

Now go the the main configuration file. our configuration for spring security will be in this file.

java
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
@EnableMethodSecurity(securedEnabled = true, prePostEnabled = false)
public class WebSecurityConfiguration {
}
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
@EnableMethodSecurity(securedEnabled = true, prePostEnabled = false)
public class WebSecurityConfiguration {
}

First, let create some whitelist route that we don’t want to apply the security filters

java
@Bean
  public WebSecurityCustomizer webSecurityCustomizer() {
    return (web) -> web.ignoring()
        .requestMatchers("/",
            "/*.html",
            "/favicon.ico",
            "/*/*.html",
            "/*/*.css",
            "/*/*.js",
            "/h2-console/**");
  }
@Bean
  public WebSecurityCustomizer webSecurityCustomizer() {
    return (web) -> web.ignoring()
        .requestMatchers("/",
            "/*.html",
            "/favicon.ico",
            "/*/*.html",
            "/*/*.css",
            "/*/*.js",
            "/h2-console/**");
  }

To exemplify the case where we have multiple SecurityFilterChain. I’ll create a SecurityFilterChain for the auth-related routes

  • The code

    java
    @Bean
    @Order(1)
    public SecurityFilterChain filterChainAuth(HttpSecurity http) throws Exception {
        http
            .securityMatchers(
                (securityMatcher) -> securityMatcher
                    .requestMatchers(
                        "/api/auth/**")
            )
            .csrf(AbstractHttpConfigurer::disable)
            .exceptionHandling(
                exceptionHandlingConfigurer -> exceptionHandlingConfigurer
                    .authenticationEntryPoint(jwtAuthenticationEntryPoint)
                    .accessDeniedHandler(jwtAccessDeniedHandler)
            )
            .authorizeHttpRequests(
                (authorize) -> authorize
                    .requestMatchers("/api/auth/**").permitAll()
                    .anyRequest()
                    .authenticated()
            );
        return http.build();
      }
    @Bean
    @Order(1)
    public SecurityFilterChain filterChainAuth(HttpSecurity http) throws Exception {
        http
            .securityMatchers(
                (securityMatcher) -> securityMatcher
                    .requestMatchers(
                        "/api/auth/**")
            )
            .csrf(AbstractHttpConfigurer::disable)
            .exceptionHandling(
                exceptionHandlingConfigurer -> exceptionHandlingConfigurer
                    .authenticationEntryPoint(jwtAuthenticationEntryPoint)
                    .accessDeniedHandler(jwtAccessDeniedHandler)
            )
            .authorizeHttpRequests(
                (authorize) -> authorize
                    .requestMatchers("/api/auth/**").permitAll()
                    .anyRequest()
                    .authenticated()
            );
        return http.build();
      }
    • The @Order(1) means I want this SecurityFilterChain to be first one
    • The requestMachers in securityMatchers indicate the set of URL patterns of the requests should be handled by this SecurityFilterChain
    • The exceptionHandling handling declares the authenticationEntryPoint and the accessDeniedHandler for this SecurityFilterChain
    • The authorizeHttpRequests declare the rules for authorization. The .anyRequest().authenticated() indicate that all other request must be authenticated. The permitAll() indicates that all the routes match the request matchers won’t have to be check for authorization

Next, we will create a custom SecurityFilterChain for post-related routes

  • The code

    java
    @Bean
    @Order(2)
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
            .securityMatchers(
                (securityMatcher) -> securityMatcher
                    .requestMatchers(
                        "/api/posts/**")
            )
            .exceptionHandling(
                exceptionHandlingConfigurer -> exceptionHandlingConfigurer
                    .authenticationEntryPoint(jwtAuthenticationEntryPoint)
    								.accessDeniedHandler(jwtAccessDeniedHandler)
            )
            .authorizeHttpRequests(
                (authorize) -> authorize
                    .anyRequest()
                    .authenticated()
            )
            .addFilterBefore(
                jwtAuthenticationFilter(),
                UsernamePasswordAuthenticationFilter.class
            );
        return http.build();
      }
    @Bean
    @Order(2)
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
            .securityMatchers(
                (securityMatcher) -> securityMatcher
                    .requestMatchers(
                        "/api/posts/**")
            )
            .exceptionHandling(
                exceptionHandlingConfigurer -> exceptionHandlingConfigurer
                    .authenticationEntryPoint(jwtAuthenticationEntryPoint)
    								.accessDeniedHandler(jwtAccessDeniedHandler)
            )
            .authorizeHttpRequests(
                (authorize) -> authorize
                    .anyRequest()
                    .authenticated()
            )
            .addFilterBefore(
                jwtAuthenticationFilter(),
                UsernamePasswordAuthenticationFilter.class
            );
        return http.build();
      }
    • We add our custom SecurityFilter - JwtAuthenticationFilter to the SecurityFilterChain by using .addFilterBefore (note that we also have other method like addFilter, addFilterAt, addFilterAfter ). The jwtAuthenticationFilter() is a private function which creates an instance of JwtAuthenticationFilter

      java
      private JwtAuthenticationFilter jwtAuthenticationFilter() {
          return new JwtAuthenticationFilter(jwtProvider);
      }
      private JwtAuthenticationFilter jwtAuthenticationFilter() {
          return new JwtAuthenticationFilter(jwtProvider);
      }

So the completed version of the WebSecurityConfig would be

  • The code

    java
    @Configuration
    @EnableWebSecurity
    @RequiredArgsConstructor
    @EnableMethodSecurity(securedEnabled = true, prePostEnabled = false)
    public class WebSecurityConfiguration {
      private final JwtProvider jwtProvider;
      private final JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint;
      private final JwtAccessDeniedHandler jwtAccessDeniedHandler;
    
      @Bean
      public WebSecurityCustomizer webSecurityCustomizer() {
        return (web) -> web.ignoring()
            .requestMatchers("/",
                "/*.html",
                "/favicon.ico",
                "/*/*.html",
                "/*/*.css",
                "/*/*.js",
                "/h2-console/**",
                "/swagger-ui/**");
      }
    
      @Bean
      @Order(1)
      public SecurityFilterChain filterChainAuth(HttpSecurity http) 
    throws Exception {
        http
            .securityMatchers(
                (securityMatcher) -> securityMatcher
                    .requestMatchers(
                        "/api/auth/**")
            )
            .csrf(AbstractHttpConfigurer::disable)
            .exceptionHandling(
                exceptionHandlingConfigurer -> exceptionHandlingConfigurer
                    .authenticationEntryPoint(jwtAuthenticationEntryPoint)
                    .accessDeniedHandler(jwtAccessDeniedHandler)
            )
            .authorizeHttpRequests(
                (authorize) -> authorize
                    .requestMatchers("/api/auth/**").permitAll()
                    .anyRequest()
                    .authenticated()
            );
        return http.build();
      }
    
      @Bean
      @Order(2)
      public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
            .securityMatchers(
                (securityMatcher) -> securityMatcher
                    .requestMatchers(
                        "/api/posts/**")
            )
            .csrf(AbstractHttpConfigurer::disable)
            .exceptionHandling(
                exceptionHandlingConfigurer -> exceptionHandlingConfigurer
                    .authenticationEntryPoint(jwtAuthenticationEntryPoint)
                    .accessDeniedHandler(jwtAccessDeniedHandler)
            )
            .authorizeHttpRequests(
                (authorize) -> authorize
                    .anyRequest()
                    .authenticated()
            )
            .addFilterBefore(
                jwtAuthenticationFilter(),
                UsernamePasswordAuthenticationFilter.class
            );
        return http.build();
      }
    
      private JwtAuthenticationFilter jwtAuthenticationFilter() {
        return new JwtAuthenticationFilter(jwtProvider);
      }
    }
    @Configuration
    @EnableWebSecurity
    @RequiredArgsConstructor
    @EnableMethodSecurity(securedEnabled = true, prePostEnabled = false)
    public class WebSecurityConfiguration {
      private final JwtProvider jwtProvider;
      private final JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint;
      private final JwtAccessDeniedHandler jwtAccessDeniedHandler;
    
      @Bean
      public WebSecurityCustomizer webSecurityCustomizer() {
        return (web) -> web.ignoring()
            .requestMatchers("/",
                "/*.html",
                "/favicon.ico",
                "/*/*.html",
                "/*/*.css",
                "/*/*.js",
                "/h2-console/**",
                "/swagger-ui/**");
      }
    
      @Bean
      @Order(1)
      public SecurityFilterChain filterChainAuth(HttpSecurity http) 
    throws Exception {
        http
            .securityMatchers(
                (securityMatcher) -> securityMatcher
                    .requestMatchers(
                        "/api/auth/**")
            )
            .csrf(AbstractHttpConfigurer::disable)
            .exceptionHandling(
                exceptionHandlingConfigurer -> exceptionHandlingConfigurer
                    .authenticationEntryPoint(jwtAuthenticationEntryPoint)
                    .accessDeniedHandler(jwtAccessDeniedHandler)
            )
            .authorizeHttpRequests(
                (authorize) -> authorize
                    .requestMatchers("/api/auth/**").permitAll()
                    .anyRequest()
                    .authenticated()
            );
        return http.build();
      }
    
      @Bean
      @Order(2)
      public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
            .securityMatchers(
                (securityMatcher) -> securityMatcher
                    .requestMatchers(
                        "/api/posts/**")
            )
            .csrf(AbstractHttpConfigurer::disable)
            .exceptionHandling(
                exceptionHandlingConfigurer -> exceptionHandlingConfigurer
                    .authenticationEntryPoint(jwtAuthenticationEntryPoint)
                    .accessDeniedHandler(jwtAccessDeniedHandler)
            )
            .authorizeHttpRequests(
                (authorize) -> authorize
                    .anyRequest()
                    .authenticated()
            )
            .addFilterBefore(
                jwtAuthenticationFilter(),
                UsernamePasswordAuthenticationFilter.class
            );
        return http.build();
      }
    
      private JwtAuthenticationFilter jwtAuthenticationFilter() {
        return new JwtAuthenticationFilter(jwtProvider);
      }
    }

a. Test login via Postman — figure 2

Note that if I put the Authorization header and input there an invalid token, we do not encounter any error

a. Test login via Postman

b. Test sign up via Postman

If I do not specify the token

c. Test get list of post via Postman

If I specify an invalid token

c. Test get list of post via Postman (2)

In both scenarios, when we check the JwtAuthenticationEndpoint, we can see the error

c. Test get list of post via Postman (3)

When I input a valid token, the response is returned

c. Test get list of post via Postman (4)

When you check in the console, you can actually see the list of SecurityFilter as well as Filter that the request has to go through

a. View filter list

Be careful when you declare your filter as a Spring bean, either by annotating it with @Component or by declaring it as a bean in your configuration, because Spring Boot will automatically register it with the embedded container. That may cause the filter to be invoked twice, once by the container and once by Spring Security and in a different order.

— From Spring Docs

Let’s try declare JwtAuthenticationFilter as a Spring Bean and add a log at the beginning of the doInternalFilter function

  • The code

    java
    @Log4j2
    @Component
    @RequiredArgsConstructor
    public class JwtAuthenticationFilter extends OncePerRequestFilter {
      public static final String AUTHORIZATION_HEADER = "Authorization";
    
      private final JwtProvider jwtProvider;
    
      @Override
      protected void doFilterInternal(
          @NonNull HttpServletRequest request,
          @NonNull HttpServletResponse response,
          @NonNull FilterChain filterChain
      ) throws ServletException, IOException {
        System.out.println("JwtAuthenticationFilter doFilterInternal is called");
        String jwt = resolveToken(request);
        String requestURI = request.getRequestURI();
        if (StringUtils.hasText(jwt)) {
          final boolean isTokenValid = jwtProvider.validateToken(jwt);
          if (isTokenValid) {
            final Authentication authentication = jwtProvider.getAuthentication(jwt);
            SecurityContextHolder.getContext().setAuthentication(authentication);
            log.debug("set Authentication to security context for '{}', uri: {}", authentication.getName(), requestURI);
          } else {
            log.debug("Invalid token for uri: {}", requestURI);
          }
        }
        filterChain.doFilter(request, response);
      }
    
      private String resolveToken(HttpServletRequest request) {
        String bearerToken = request.getHeader(AUTHORIZATION_HEADER);
        if (StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer ")) {
          return bearerToken.substring(7);
        }
        return null;
      }
    }
    @Log4j2
    @Component
    @RequiredArgsConstructor
    public class JwtAuthenticationFilter extends OncePerRequestFilter {
      public static final String AUTHORIZATION_HEADER = "Authorization";
    
      private final JwtProvider jwtProvider;
    
      @Override
      protected void doFilterInternal(
          @NonNull HttpServletRequest request,
          @NonNull HttpServletResponse response,
          @NonNull FilterChain filterChain
      ) throws ServletException, IOException {
        System.out.println("JwtAuthenticationFilter doFilterInternal is called");
        String jwt = resolveToken(request);
        String requestURI = request.getRequestURI();
        if (StringUtils.hasText(jwt)) {
          final boolean isTokenValid = jwtProvider.validateToken(jwt);
          if (isTokenValid) {
            final Authentication authentication = jwtProvider.getAuthentication(jwt);
            SecurityContextHolder.getContext().setAuthentication(authentication);
            log.debug("set Authentication to security context for '{}', uri: {}", authentication.getName(), requestURI);
          } else {
            log.debug("Invalid token for uri: {}", requestURI);
          }
        }
        filterChain.doFilter(request, response);
      }
    
      private String resolveToken(HttpServletRequest request) {
        String bearerToken = request.getHeader(AUTHORIZATION_HEADER);
        if (StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer ")) {
          return bearerToken.substring(7);
        }
        return null;
      }
    }

We have to update the WebSecurityConfiguration as well

  • The code

    java
    @Configuration
    @EnableWebSecurity
    @RequiredArgsConstructor
    @EnableMethodSecurity(securedEnabled = true, prePostEnabled = false)
    public class WebSecurityConfiguration {
      private final JwtProvider jwtProvider;
      private final JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint;
      private final JwtAccessDeniedHandler jwtAccessDeniedHandler;
      private final JwtAuthenticationFilter jwtAuthenticationFilter; // inject jwtAuthenticationFilter bean
    
      @Bean
      public WebSecurityCustomizer webSecurityCustomizer() {
        return (web) -> web.ignoring()
            .requestMatchers("/",
                "/*.html",
                "/favicon.ico",
                "/*/*.html",
                "/*/*.css",
                "/*/*.js",
                "/h2-console/**",
                "/swagger-ui/**");
      }
    
      @Bean
      @Order(1)
      public SecurityFilterChain filterChainAuth(HttpSecurity http) throws Exception {
        http
            .securityMatchers(
                (securityMatcher) -> securityMatcher
                    .requestMatchers(
                        "/api/auth/**")
            )
            .csrf(AbstractHttpConfigurer::disable)
            .exceptionHandling(
                httpSecurityExceptionHandlingConfigurer -> httpSecurityExceptionHandlingConfigurer
                    .authenticationEntryPoint(jwtAuthenticationEntryPoint)
                    .accessDeniedHandler(jwtAccessDeniedHandler)
            )
            .authorizeHttpRequests(
                (authorize) -> authorize
                    .requestMatchers("/api/auth/**").permitAll()
                    .anyRequest()
                    .authenticated()
            );
        return http.build();
      }
    
      @Bean
      @Order(2)
      public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
            .securityMatchers(
                (securityMatcher) -> securityMatcher
                    .requestMatchers(
                        "/api/posts/**")
            )
            .csrf(AbstractHttpConfigurer::disable)
            .exceptionHandling(
                httpSecurityExceptionHandlingConfigurer -> httpSecurityExceptionHandlingConfigurer
                    .authenticationEntryPoint(jwtAuthenticationEntryPoint)
                    .accessDeniedHandler(jwtAccessDeniedHandler)
            )
            .authorizeHttpRequests(
                (authorize) -> authorize
                    .anyRequest()
                    .authenticated()
            )
            .addFilterBefore(
                jwtAuthenticationFilter, // change to use jwtAuthenticationFilter bean
                UsernamePasswordAuthenticationFilter.class
            );
        return http.build();
      }
    }
    @Configuration
    @EnableWebSecurity
    @RequiredArgsConstructor
    @EnableMethodSecurity(securedEnabled = true, prePostEnabled = false)
    public class WebSecurityConfiguration {
      private final JwtProvider jwtProvider;
      private final JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint;
      private final JwtAccessDeniedHandler jwtAccessDeniedHandler;
      private final JwtAuthenticationFilter jwtAuthenticationFilter; // inject jwtAuthenticationFilter bean
    
      @Bean
      public WebSecurityCustomizer webSecurityCustomizer() {
        return (web) -> web.ignoring()
            .requestMatchers("/",
                "/*.html",
                "/favicon.ico",
                "/*/*.html",
                "/*/*.css",
                "/*/*.js",
                "/h2-console/**",
                "/swagger-ui/**");
      }
    
      @Bean
      @Order(1)
      public SecurityFilterChain filterChainAuth(HttpSecurity http) throws Exception {
        http
            .securityMatchers(
                (securityMatcher) -> securityMatcher
                    .requestMatchers(
                        "/api/auth/**")
            )
            .csrf(AbstractHttpConfigurer::disable)
            .exceptionHandling(
                httpSecurityExceptionHandlingConfigurer -> httpSecurityExceptionHandlingConfigurer
                    .authenticationEntryPoint(jwtAuthenticationEntryPoint)
                    .accessDeniedHandler(jwtAccessDeniedHandler)
            )
            .authorizeHttpRequests(
                (authorize) -> authorize
                    .requestMatchers("/api/auth/**").permitAll()
                    .anyRequest()
                    .authenticated()
            );
        return http.build();
      }
    
      @Bean
      @Order(2)
      public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
            .securityMatchers(
                (securityMatcher) -> securityMatcher
                    .requestMatchers(
                        "/api/posts/**")
            )
            .csrf(AbstractHttpConfigurer::disable)
            .exceptionHandling(
                httpSecurityExceptionHandlingConfigurer -> httpSecurityExceptionHandlingConfigurer
                    .authenticationEntryPoint(jwtAuthenticationEntryPoint)
                    .accessDeniedHandler(jwtAccessDeniedHandler)
            )
            .authorizeHttpRequests(
                (authorize) -> authorize
                    .anyRequest()
                    .authenticated()
            )
            .addFilterBefore(
                jwtAuthenticationFilter, // change to use jwtAuthenticationFilter bean
                UsernamePasswordAuthenticationFilter.class
            );
        return http.build();
      }
    }

Now let’s try re-run the application, I’ll try calling the auth-related route, which is not registered with the JwtAuthenticationFilter

As you can see that, the JwtAuthenticationFilter.onInternalFilter is called even though the SecurityFilterChain of auth - related route does not contain the JwtAuthenticationFilter

b. Don’t declare Filter as Bean

Now, revert all the code to use the version when we declare jwtAuthenticationFilter by initializing a new instance of that class

Make an API call again, and we don’t see any call of JwtAuthenticationFilter.onInternalFilter

b. Don’t declare Filter as Bean (2)

But in case you really want to declare the Filter as a Spring Bean , you can tell Spring Boot to not register it with the container by declaring a FilterRegistrationBean bean and setting its enabled property to false

Adding this to WebSecurityConfiguration file:

java
@Bean
  public FilterRegistrationBean<JwtAuthenticationFilter> jwtAuthenticationFilterRegistration(
      JwtAuthenticationFilter jwtAuthenticationFilter
  ) {
    FilterRegistrationBean<JwtAuthenticationFilter> registration =
        new FilterRegistrationBean<>(jwtAuthenticationFilter);
    registration.setEnabled(false);
    return registration;
  }
@Bean
  public FilterRegistrationBean<JwtAuthenticationFilter> jwtAuthenticationFilterRegistration(
      JwtAuthenticationFilter jwtAuthenticationFilter
  ) {
    FilterRegistrationBean<JwtAuthenticationFilter> registration =
        new FilterRegistrationBean<>(jwtAuthenticationFilter);
    registration.setEnabled(false);
    return registration;
  }

Source code:

You can find the full source code at:

trunghieu99tt/spring-security-jwt-authentication (github.com)

References:

Architecture :: Spring Security

Advanced Spring Security - How to create multiple Spring Security Configurations (danvega.dev)

Tagged:#Backend#Web Development
0