Implementing JWT Authentication in a Spring Boot + React Application

박진석·2025년 2월 10일
0

FindMyBMW

목록 보기
5/10
post-thumbnail

JWT Authentication with Spring Boot and React

Table of Contents

  1. Introduction
  2. Backend Implementation
  3. Frontend Implementation
  4. Understanding JWT Flow
  5. Best Practices and Security Considerations

Introduction

JSON Web Token (JWT) authentication is a stateless authentication mechanism that's become increasingly popular in modern web applications. We will walk you through implementing a complete authentication system using Spring Boot for the backend and React for the frontend.

What is JWT?

A JWT consists of three parts:
1. Header - Contains the type of token and the signing algorithm
2. Payload - Contains the claims (user data)
3. Signature - Used to verify the token hasn't been tampered with

Example JWT structure:

aaaa.
bbbb.
cccc

Backend Implementation

1. Project Setup

First, set up your Spring Boot project with the necessary dependencies in build.gradle:

dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
    implementation 'org.springframework.boot:spring-boot-starter-security'
    implementation 'org.springframework.boot:spring-boot-starter-web'
    implementation 'io.jsonwebtoken:jjwt-api:0.11.5'
    runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.11.5'
    runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.11.5'
    compileOnly 'org.projectlombok:lombok'
    annotationProcessor 'org.projectlombok:lombok'
}

2. User Entity

Create the User entity class to represent users in your database:

@Entity
@Table(name = "users")
@Data
public class Users {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "user_id")
    private Integer id;

    @Column(name = "created_at")
    private Date created_at;

    @Column(name = "email")
    private String email;

    @Column(name = "password_hash")
    private String password_hash;

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

    @Column(name = "updated_at")
    private Date updated_at;
}

This entity class:

  • Uses JPA annotations to map to database table
  • Uses Lombok's @Data for getters/setters
  • Includes fields for user management and auditing

3. JWT Token Utility

Create a utility class to handle JWT operations:

@Component
public class JwtTokenUtil {
    @Value("${jwt.secret}")
    private String secret;

    @Value("${jwt.expiration}")
    private Long expiration;

    private SecretKey getSigningKey() {
        byte[] keyBytes = secret.getBytes(StandardCharsets.UTF_8);
        return Keys.hmacShaKeyFor(keyBytes);
    }

    public String generateToken(String username) {
        Map<String, Object> claims = new HashMap<>();
        return createToken(claims, username);
    }

    private String createToken(Map<String, Object> claims, String subject) {
        return Jwts.builder()
                .setClaims(claims)
                .setSubject(subject)
                .setIssuedAt(new Date(System.currentTimeMillis()))
                .setExpiration(new Date(System.currentTimeMillis() + expiration * 1000))
                .signWith(getSigningKey(), SignatureAlgorithm.HS256)
                .compact();
    }

    public String extractUsername(String token) {
        return extractClaim(token, Claims::getSubject);
    }

    private Claims extractAllClaims(String token) {
        return Jwts.parserBuilder()
                .setSigningKey(getSigningKey())
                .build()
                .parseClaimsJws(token)
                .getBody();
    }

    public Boolean validateToken(String token, UserDetails userDetails) {
        final String username = extractUsername(token);
        return (username.equals(userDetails.getUsername()) && !isTokenExpired(token));
    }
}

Key points about the JWT utility:

  • Uses a secret key for signing tokens
  • Implements token generation and validation
  • Handles claim extraction
  • Uses HS256 algorithm for signing

4. Security Configuration

Configure Spring Security to use JWT:

@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {
    private final JwtAuthenticationFilter jwtAuthFilter;
    private final CustomUserDetailsService userDetailsService;

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http
            .csrf(csrf -> csrf.disable())
            .cors(cors -> cors.configurationSource(corsConfigurationSource()))
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/api/auth/**").permitAll()
                .anyRequest().authenticated()
            )
            .sessionManagement(session -> session
                .sessionCreationPolicy(SessionCreationPolicy.STATELESS)
            )
            .authenticationProvider(authenticationProvider())
            .addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class);

        return http.build();
    }

    @Bean
    public CorsConfigurationSource corsConfigurationSource() {
        CorsConfiguration configuration = new CorsConfiguration();
        configuration.setAllowedOrigins(List.of("http://localhost:3000"));
        configuration.setAllowedMethods(Arrays.asList("GET", "POST", "PUT", "DELETE", "OPTIONS"));
        configuration.setAllowedHeaders(Arrays.asList("Authorization", "Content-Type"));
        configuration.setExposedHeaders(List.of("Authorization"));
        configuration.setAllowCredentials(true);
        
        UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
        source.registerCorsConfiguration("/**", configuration);
        return source;
    }

    @Bean
    public AuthenticationProvider authenticationProvider() {
        DaoAuthenticationProvider authProvider = new DaoAuthenticationProvider();
        authProvider.setUserDetailsService(userDetailsService);
        authProvider.setPasswordEncoder(passwordEncoder());
        return authProvider;
    }

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

Important security configuration aspects:

  • Disables CSRF for stateless APIs
  • Configures CORS for frontend access
  • Sets up stateless session management
  • Configures authentication provider and password encoder
  • Adds JWT filter to the security chain

5. JWT Authentication Filter

Create a filter to process JWT tokens:

@Component
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {
    private final JwtTokenUtil jwtTokenUtil;
    private final CustomUserDetailsService userDetailsService;

    @Override
    protected void doFilterInternal(
            HttpServletRequest request,
            HttpServletResponse response,
            FilterChain filterChain
    ) throws ServletException, IOException {
        final String authHeader = request.getHeader("Authorization");
        final String jwt;
        final String username;

        if (authHeader == null || !authHeader.startsWith("Bearer ")) {
            filterChain.doFilter(request, response);
            return;
        }

        jwt = authHeader.substring(7);
        username = jwtTokenUtil.extractUsername(jwt);

        if (username != null && SecurityContextHolder.getContext().getAuthentication() == null) {
            UserDetails userDetails = this.userDetailsService.loadUserByUsername(username);
            
            if (jwtTokenUtil.validateToken(jwt, userDetails)) {
                UsernamePasswordAuthenticationToken authToken = new UsernamePasswordAuthenticationToken(
                    userDetails,
                    null,
                    userDetails.getAuthorities()
                );
                authToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
                SecurityContextHolder.getContext().setAuthentication(authToken);
            }
        }
        filterChain.doFilter(request, response);
    }
}

The filter:

  • Extracts JWT from Authorization header
  • Validates the token
  • Sets up the security context if token is valid

6. Authentication Controller

Create endpoints for authentication:

@RestController
@RequestMapping("/api/auth")
@CrossOrigin(origins = "http://localhost:3000")
@RequiredArgsConstructor
public class AuthController {
    private final AuthenticationManager authenticationManager;
    private final JwtTokenUtil jwtTokenUtil;
    private final UsersRepository usersRepository;
    private final PasswordEncoder passwordEncoder;

    @PostMapping("/signup")
    public ResponseEntity<?> signup(@RequestBody SignupRequest request) {
        // Validate if user exists
        if (usersRepository.findByUsername(request.getUsername()).isPresent()) {
            return ResponseEntity.badRequest().body("Username already taken");
        }

        // Create new user
        Users user = new Users();
        user.setUsername(request.getUsername());
        user.setEmail(request.getEmail());
        user.setPassword_hash(passwordEncoder.encode(request.getPassword()));
        user.setCreated_at(new Date());
        user.setUpdated_at(new Date());

        usersRepository.save(user);

        // Generate token
        String jwt = jwtTokenUtil.generateToken(user.getUsername());
        return ResponseEntity.ok(new AuthResponse(jwt));
    }

    @PostMapping("/login")
    public ResponseEntity<?> login(@RequestBody LoginRequest request) {
        try {
            authenticationManager.authenticate(
                new UsernamePasswordAuthenticationToken(request.getUsername(), request.getPassword())
            );
        } catch (BadCredentialsException e) {
            return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body("Invalid credentials");
        }

        String jwt = jwtTokenUtil.generateToken(request.getUsername());
        return ResponseEntity.ok(new AuthResponse(jwt));
    }
}

Frontend Implementation

1. API Service

Create a service to handle API calls:

import axios from 'axios';

const API_URL = 'http://localhost:8080/api';

const api = axios.create({
    baseURL: API_URL,
    headers: {
        'Content-Type': 'application/json'
    }
});

// Add request interceptor to include JWT token
api.interceptors.request.use(
    (config) => {
        const token = localStorage.getItem('token');
        if (token) {
            config.headers.Authorization = `Bearer ${token}`;
        }
        return config;
    },
    (error) => {
        return Promise.reject(error);
    }
);

// Add response interceptor to handle token expiration
api.interceptors.response.use(
    (response) => response,
    (error) => {
        if (error.response.status === 401) {
            localStorage.removeItem('token');
            window.location.href = '/login';
        }
        return Promise.reject(error);
    }
);

export default api;

2. Auth Context

Create an authentication context to manage auth state:

import React, { createContext, useContext, useState, useEffect } from 'react';

const AuthContext = createContext(null);

export const AuthProvider = ({ children }) => {
    const [isAuthenticated, setIsAuthenticated] = useState(false);
    const [isLoading, setIsLoading] = useState(true);

    useEffect(() => {
        const token = localStorage.getItem('token');
        setIsAuthenticated(!!token);
        setIsLoading(false);
    }, []);

    const login = (token) => {
        localStorage.setItem('token', token);
        setIsAuthenticated(true);
    };

    const logout = () => {
        localStorage.removeItem('token');
        setIsAuthenticated(false);
    };

    if (isLoading) {
        return <div>Loading...</div>;
    }

    return (
        <AuthContext.Provider value={{ isAuthenticated, login, logout }}>
            {children}
        </AuthContext.Provider>
    );
};

export const useAuth = () => useContext(AuthContext);

3. Login Component

Create a login form component:

import React, { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useAuth } from '../context/AuthContext';
import api from '../services/api';

function Login() {
    const [username, setUsername] = useState('');
    const [password, setPassword] = useState('');
    const [error, setError] = useState('');
    const navigate = useNavigate();
    const { login } = useAuth();

    const handleSubmit = async (e) => {
        e.preventDefault();
        setError('');
        
        try {
            const response = await api.post('/auth/login', {
                username,
                password
            });
            
            login(response.data.token);
            navigate('/dashboard');
        } catch (err) {
            setError(err.response?.data || 'An error occurred');
        }
    };

    return (
        <div className="login-container">
            <form onSubmit={handleSubmit}>
                {error && <div className="error">{error}</div>}
                <div>
                    <label>Username:</label>
                    <input
                        type="text"
                        value={username}
                        onChange={(e) => setUsername(e.target.value)}
                        required
                    />
                </div>
                <div>
                    <label>Password:</label>
                    <input
                        type="password"
                        value={password}
                        onChange={(e) => setPassword(e.target.value)}
                        required
                    />
                </div>
                <button type="submit">Login</button>
            </form>
        </div>
    );
}

export default Login;

Understanding JWT Flow

  1. User Signs Up:

    • User submits registration form
    • Backend validates and creates user
    • Backend generates JWT and returns it
    • Frontend stores JWT in localStorage
  2. User Logs In:

    • User submits credentials
    • Backend validates credentials
    • Backend generates new JWT and returns it
    • Frontend stores JWT in localStorage
  3. Making Authenticated Requests:

    • Frontend includes JWT in Authorization header
    • Backend JWT filter validates token
    • If valid, request proceeds
    • If invalid, returns 401 error
  4. Token Expiration:

    • JWT includes expiration time
    • Backend checks expiration during validation
    • Frontend handles 401 responses by redirecting to login

Best Practices and Security Considerations

  1. Token Storage:

    • Prefer HttpOnly cookies over localStorage for better security
    • Consider implementing refresh tokens
    • Clear tokens on logout
  2. Password Security:

    • Always hash passwords before storing
    • Use strong password policies
    • Implement rate limiting for login attempts
  3. CORS Configuration:

    • Only allow necessary origins
    • Specify allowed methods and headers
    • Be careful with credentials
  4. Token Configuration:

    • Use appropriate expiration times
    • Include minimal

Security Considerations

  1. Password Storage: Always hash passwords before storing them in the DB. We use BCrypt in this implementation.

  2. Token Security:

    • Store tokens securely (use HttpOnly cookies for better security)
    • Include expiration time in tokens
    • Implement token refresh mechanism for long-term sessions
  3. CORS Configuration: Properly configure CORS to only allow requests from trusted domains.

  4. Error Handling: Never expose sensitive information in error messages.

Authentication Flow

  1. Signup Process:

    • User submits username, email, and password
    • Backend validates input and checks for existing users
    • Password is hashed
    • User is saved to DB
    • JWT token is generated and returned
  2. Login Process:

    • User submits username and password
    • Backend authenticates credentials
    • JWT token is generated and returned
    • Frontend stores token in localStorage
    • Token is included in subsequent requests
  3. Protected Route Access:

    • Frontend includes JWT token in Authorization header
    • Backend validates token
    • If valid, request proceeds
    • If invalid, returns 401/403 error

Testing

Always test your authentication implementation thoroughly:

  1. Test signup with valid and invalid data
  2. Test login with correct and incorrect credentials
  3. Test token expiration handling
  4. Test concurrent login handling

Common Issues and Solutions

  1. CORS Errors: Ensure proper CORS configuration in both frontend and backend.

  2. Token Expiration: Implement proper token refresh mechanism or handle expiration gracefully.

  3. Security Headers: Configure security headers (HSTS, CSP, etc.) for production.

Conclusion

JWT authentication provides a secure and scalable way to handle user authentication in modern web applications. While this implementation provides a solid foundation, consider adding features like:

  • Password reset functionality
  • Email verification
  • Remember me functionality
  • Session management
  • Token refresh mechanism
  • Rate limiting

Example

  1. Sign Up

  2. Login

  3. Succeed

  4. DB Table

0개의 댓글