Adem TONAY @ ademtonay.com

Authentication with JWT and Spring Boot

Dec 3 · 6 min

In web applications, security, especially authentication and authorization processes, plays a critical role. In this article, I will explain in detail how to perform user authentication using JWT (JSON Web Token) in a Spring Boot application.

1. Adding Required Dependencies

To perform JWT-based authentication and authorization in a Spring Boot application, you need to add some essential dependencies to your project. These dependencies are necessary for security and JWT operations. Below is how to add these dependencies to your Spring Boot project.

If you are using Maven:

You can include the required libraries in your pom.xml file by adding the following dependencies.

<dependencies>
    <!-- Spring Security -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-security</artifactId>
    </dependency>
    <!-- JJWT (Java JWT) -->
    <dependency>
        <groupId>io.jsonwebtoken</groupId>
        <artifactId>jjwt-api</artifactId>
        <version>0.11.5</version>
    </dependency>
    <dependency>
        <groupId>io.jsonwebtoken</groupId>
        <artifactId>jjwt-impl</artifactId>
        <version>0.11.5</version>
        <scope>runtime</scope>
    </dependency>
    <dependency>
        <groupId>io.jsonwebtoken</groupId>
        <artifactId>jjwt-jackson</artifactId>
        <version>0.11.5</version>
        <scope>runtime</scope>
    </dependency>
</dependencies>

If you are using Gradle:

If you are using Gradle, you can add the following dependencies to your build.gradle file.

dependencies {
  // Spring Security
  implementation 'org.springframework.boot:spring-boot-starter-security'

  // JJWT (Java JWT) API, Implementation, and Jackson support
  implementation 'io.jsonwebtoken:jjwt-api:0.11.5'
  runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.11.5'
  runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.11.5'
}

2. Storing User Information Securely: AuthDto

As the first step, we need to create a DTO (Data Transfer Object) class to store user information. This class will represent the information that will be included in the JWT token. For example, it might include user ID, email address, and roles:

@Builder
@Getter
@Setter
public class AuthDto {
    private UUID id;
    private String email;
    private List<GrantedAuthority> roles;
}

This DTO will carry the user information and will be used securely within the SecurityContext.

3. JWT Creation and Validation: TokenService

We need to write a service class that will handle the creation and validation of the JWT. This class will manage all operations from generating the token to validation and constructing the authentication context.

@RequiredArgsConstructor
@Service
public class TokenService implements ITokenService {
    private final IUserRepository userRepository;
    private final int MILLISECOND_IN_MINUTE = 1000 * 60; // Milliseconds per minute
    private final SignatureAlgorithm SIGNATURE_ALGORITHM = SignatureAlgorithm.HS256; // Signing algorithm

    @Value("${security.token.access.secret}")
    private String accessTokenSecret; // Secret key for Access token

    @Value("${security.token.access.expires}")
    private int accessTokenExpires; // Expiration time for Access token (in minutes)

    // Generating JWT from User object
    @Override
    public String generateAccessToken(User user) {
        Date now = new Date(); // Current time
        int expiresIn = accessTokenExpires * MILLISECOND_IN_MINUTE; // Expiration time in milliseconds

        // Create JWT token
        return Jwts.builder()
                .setSubject(user.getEmail()) // User's email
                .claim("id", user.getId()) // User's ID
                .claim("roles", rolesToString(user.getRoles())) // User's roles as a string
                .setIssuedAt(now) // Token issued at
                .setExpiration(new Date(now.getTime() + expiresIn)) // Token expiration time
                .signWith(jwtKeyGenerator(accessTokenSecret), SIGNATURE_ALGORITHM) // JWT signing
                .compact(); // Generate the token
    }

    // Generating authentication from JWT token
    @Override
    public Authentication getAuthenticationFromAccessToken(String accessToken) {
        Claims claims = Jwts.parserBuilder()
                .setSigningKey(jwtKeyGenerator(accessTokenSecret)) // Verify the JWT signature using the secret key
                .build().parseClaimsJws(accessToken).getBody(); // Parse the JWT token

        // Get roles from token and convert to GrantedAuthority
        List<GrantedAuthority> roles = stringToRoles(claims.get("roles", String.class));

        // Build Authentication object with user information
        AuthDto authDto = AuthDto.builder()
                .id(UUID.fromString(claims.get("id", String.class))) // User's UUID
                .email(claims.getSubject()) // User's email
                .roles(roles) // User's roles
                .build();

        // Return Authentication object
        return new UsernamePasswordAuthenticationToken(authDto, null, roles);
    }

    // Convert roles to a comma-separated string
    private String rolesToString(Collection<Role> roles) {
        return roles.stream()
                .map(Role::name) // Get role names
                .collect(Collectors.joining(",")); // Join with commas
    }

    // Convert string roles to GrantedAuthority list
    private List<GrantedAuthority> stringToRoles(String rolesString) {
        if (rolesString == null || rolesString.isEmpty()) {
            return Collections.emptyList(); // Return empty list if no roles
        }

        // Convert string roles to GrantedAuthority
        return Arrays.stream(rolesString.split(",\\s*"))
                .map(role -> Role.valueOf(role)) // Convert to Role enum
                .filter(Objects::nonNull) // Filter non-null roles
                .map(role -> new SimpleGrantedAuthority(role.name())) // Convert to GrantedAuthority
                .collect(Collectors.toList());
    }

    // Generate secret key for JWT
    private Key jwtKeyGenerator(String secret) {
        return Keys.hmacShaKeyFor(Decoders.BASE64.decode(secret)); // Decode the secret key with BASE64
    }
}

4. JWT Validation with AuthTokenFilter

To check the validity of the JWT on every incoming request, we use a filter. This filter verifies the JWT, and if valid, authenticates the user.

@RequiredArgsConstructor
@Component
public class AuthTokenFilter extends OncePerRequestFilter {
    private final ITokenService tokenService; // TokenService used for JWT verification

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
        String url = request.getRequestURI(); // Get the URL of the incoming request

        // Skip JWT verification for '/api/auth/*' paths
        if (url.matches("/api/auth/*")) {
            filterChain.doFilter(request, response);
            return;
        }

        // Get access token from cookie
        Cookie accessTokenCookie = WebUtils.getCookie(request, "accessToken");

        // If no token found, attach error and proceed
        if (accessTokenCookie == null) {
            attachError(request, ErrorCode.ACCESS_TOKEN_DOES_NOT_EXIST); // Attach error message
            filterChain.doFilter(request, response);
            return;
        }

        try {
            // Validate the token and get Authentication object
            Authentication authentication = tokenService.getAuthenticationFromAccessToken(accessTokenCookie.getValue());

            // Set the authentication context
            SecurityContextHolder.getContext().setAuthentication(authentication);
        } catch (ExpiredJwtException e) {
            // Handle expired token error
            attachErrorAndClearAuthContext(request, ErrorCode.ACCESS_TOKEN_EXPIRED);
        } catch (MalformedJwtException e) {
            // Handle malformed token error
            attachErrorAndClearAuthContext(request, ErrorCode.ACCESS_TOKEN_MALFORMED);
        } catch (RuntimeException e) {
            // Handle other errors
            attachErrorAndClearAuthContext(request, ErrorCode.INTERNAL_SERVER);
        }

        filterChain.doFilter(request, response); // Continue with the filter chain
    }

    private void attachError(HttpServletRequest request, ErrorCode errorCode) {
        // Attach error information to the request
        request.setAttribute("exceptionStatus", errorCode.getHttpStatusCode());
        request.setAttribute("exceptionCode", errorCode.getCode());
        request.setAttribute("exceptionMessage", errorCode.getMessage());
    }

    private void attachErrorAndClearAuthContext(HttpServletRequest request, ErrorCode errorCode) {
        // Attach error and clear authentication context
        attachError(request, errorCode);
        SecurityContextHolder.clearContext(); // Clear authentication information
    }
}

5. Handling JWT Errors with AuthExceptionHandler

The AuthenticationEntryPoint is used to manage errors during JWT verification, sending the correct error messages to users.

@Slf4j
@Component
public class AuthExceptionHandler implements AuthenticationEntryPoint {
    final ObjectMapper MAPPER = new ObjectMapper(); // ObjectMapper for JSON responses

    @Override
    public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException {
        log.error("Unauthorized error: {}", authException.getMessage()); // Log error message

        response.setContentType(MediaType.APPLICATION_JSON_VALUE); // Set response type to JSON
        response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); // Set HTTP 401 (Unauthorized) status code

        final Map<String, Object> body = new HashMap<>(); // Map to store error details

        // If error details are present in the request, return them in the JSON response
        if (request.getAttribute("exceptionCode") != null &&
            request.getAttribute("exceptionMessage") != null &&
            request.getAttribute("exceptionStatus") != null) {
            body.put("code", request.getAttribute("exceptionCode").toString());
            body.put("message", request.getAttribute("exceptionMessage").toString());
            body.put("status", Integer.parseInt(request.getAttribute("exceptionStatus").toString()));
        } else {
            // If no error details are found, send a general authentication required error
            body.put("code", ErrorCode.AUTHENTICATION_REQUIRED.getCode());
            body.put("message", ErrorCode.AUTHENTICATION_REQUIRED.getMessage());
            body.put("status", ErrorCode.AUTHENTICATION_REQUIRED.getHttpStatusCode());
        }

        // Send JSON response
        MAPPER.writeValue(response.getOutputStream(), body);
    }
}

6. Spring Security Configuration: SecurityConfig

Finally, we include the JWT validation filter and error handler in the Spring Security configuration. This configuration will handle security and validation processes across the application.

@RequiredArgsConstructor
@Configuration
@EnableWebSecurity
public class SecurityConfig {
    private final AuthTokenFilter authTokenFilter; // Filter for JWT validation
    private final AuthExceptionHandler authExceptionHandler; // Error handler for JWT errors

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity httpSecurity) throws Exception {
        return httpSecurity
                .exceptionHandling(customizer -> customizer.authenticationEntryPoint(authExceptionHandler)) // Use the error handler
                .addFilterBefore(authTokenFilter, BasicAuthenticationFilter.class) // Add JWT validation filter
                .csrf(AbstractHttpConfigurer::disable) // Disable CSRF protection
                .sessionManagement(customizer -> customizer.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) // Stateless session management
                .authorizeHttpRequests(requests -> requests
                        .requestMatchers("/api/auth/*").permitAll() // Allow unauthenticated access to auth endpoints
                        .anyRequest().authenticated()) // Require authentication for all other requests
                .build(); // Apply configuration
    }
}

Wrapping Up

In this article, we have explored how to implement JWT-based authentication and authorization in Spring Boot applications. We covered key concepts like creating and validating JWT tokens, security filters, and error handling. If you have any questions, feel free to contact me.

>
CC BY-NC-SA 4.0 2021-PRESENT © Adem TONAY