[Spring Security] Security & JWT

✅ Spring Security + JWT + CORS + XSS 방어 전체 코드

MSA 환경에서 JWT 인증, CORS 설정, XSS 방어를 모두 포함한 완전한 Spring Security 설정을 제공합니다.
이제 모든 요청에서 JWT 검증, CORS 처리, XSS 보호를 적용할 수 있습니다.


1. pom.xml (필요한 의존성 추가)

<dependencies>
    <!-- Spring Boot Web -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>

    <!-- Spring Security -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-security</artifactId>
    </dependency>

    <!-- JWT (io.jsonwebtoken) -->
    <dependency>
        <groupId>io.jsonwebtoken</groupId>
        <artifactId>jjwt</artifactId>
        <version>0.11.5</version>
    </dependency>

    <!-- Lombok -->
    <dependency>
        <groupId>org.projectlombok</groupId>
        <artifactId>lombok</artifactId>
        <scope>provided</scope>
    </dependency>
</dependencies>

📌 Spring Security + JWT + Lombok 포함.


2. JWT 유틸리티 클래스 (JwtUtil.java)

import io.jsonwebtoken.*;
import io.jsonwebtoken.security.Keys;
import java.util.Date;
import java.util.List;
import javax.crypto.SecretKey;

public class JwtUtil {

    private static final String SECRET_KEY = "mysecretkeymysecretkeymysecretkeymysecretkey"; // 32바이트 이상 필요
    private static final long EXPIRATION_TIME = 1000 * 60 * 60; // 1시간

    private static final SecretKey key = Keys.hmacShaKeyFor(SECRET_KEY.getBytes());

    // ✅ JWT 생성 (권한 정보 포함)
    public static String generateToken(String username, List<String> roles) {
        return Jwts.builder()
                .setSubject(username)
                .claim("roles", roles) // ✅ 권한 정보 추가
                .setIssuedAt(new Date())
                .setExpiration(new Date(System.currentTimeMillis() + EXPIRATION_TIME))
                .signWith(key, SignatureAlgorithm.HS256)
                .compact();
    }

    // ✅ JWT에서 사용자 이름 가져오기
    public static String getUsername(String token) {
        return Jwts.parserBuilder()
                .setSigningKey(key)
                .build()
                .parseClaimsJws(token)
                .getBody()
                .getSubject();
    }

    // ✅ JWT에서 권한(Role) 정보 가져오기
    public static List<String> getRoles(String token) {
        return Jwts.parserBuilder()
                .setSigningKey(key)
                .build()
                .parseClaimsJws(token)
                .getBody()
                .get("roles", List.class);
    }
}

3. XSS 방어 필터 (XssFilter.java)

import javax.servlet.*;
import javax.servlet.http.HttpServletRequest;
import java.io.IOException;

public class XssFilter implements Filter {
    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
            throws IOException, ServletException {
        XssRequestWrapper wrappedRequest = new XssRequestWrapper((HttpServletRequest) request);
        chain.doFilter(wrappedRequest, response);
    }
}

4. XSS 필터를 위한 HttpServletRequestWrapper (XssRequestWrapper.java)

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletRequestWrapper;

public class XssRequestWrapper extends HttpServletRequestWrapper {

    public XssRequestWrapper(HttpServletRequest request) {
        super(request);
    }

    @Override
    public String getParameter(String name) {
        return sanitize(super.getParameter(name));
    }

    @Override
    public String[] getParameterValues(String name) {
        String[] values = super.getParameterValues(name);
        if (values == null) return null;

        for (int i = 0; i < values.length; i++) {
            values[i] = sanitize(values[i]);
        }
        return values;
    }

    private String sanitize(String input) {
        return input == null ? null : input.replaceAll("<", "&lt;")
                                           .replaceAll(">", "&gt;")
                                           .replaceAll("\"", "&quot;")
                                           .replaceAll("'", "&#x27;")
                                           .replaceAll("&", "&amp;");
    }
}

5. JWT 인증 필터 (JwtAuthenticationFilter.java)

import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.User;
import org.springframework.web.filter.OncePerRequestFilter;
import javax.servlet.*;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.util.List;
import java.util.stream.Collectors;

public class JwtAuthenticationFilter extends OncePerRequestFilter {

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

        if (token != null && token.startsWith("Bearer ")) {
            try {
                String username = JwtUtil.getUsername(token.substring(7));
                List<String> roles = JwtUtil.getRoles(token.substring(7));

                List<SimpleGrantedAuthority> authorities = roles.stream()
                        .map(SimpleGrantedAuthority::new)
                        .collect(Collectors.toList());

                User user = new User(username, "", authorities);
                SecurityContextHolder.getContext().setAuthentication(new JwtAuthenticationToken(user));

            } catch (Exception e) {
                response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "Invalid Token");
                return;
            }
        }
        
        filterChain.doFilter(request, response);
    }
}

6. Spring Security 설정 (SecurityConfig.java)

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.CorsConfigurationSource;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;

import java.util.List;

@Configuration
public class SecurityConfig {

    private final JwtConfirmFilter jwtConfirmFilter;
    public SecurityConfig(JwtConfirmFilter jwtConfirmFilter) {
        this.jwtConfirmFilter = jwtConfirmFilter;
    }

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http
            .cors(cors -> cors.configurationSource(corsConfigurationSource()))
            .csrf().disable()
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/auth/login").permitAll()
                .requestMatchers("/admin/**").hasRole("ADMIN")
                .requestMatchers("/user/**").hasAnyRole("USER", "ADMIN")
                .anyRequest().authenticated()
            )
            .addFilterBefore(jwtConfirmFilter, UsernamePasswordAuthenticationFilter.class)
            .addFilterBefore(new XssFilter(), JwtConfirmFilter.class)
            .formLogin().disable();

        return http.build();
    }

    @Bean
    public CorsConfigurationSource corsConfigurationSource() {
        CorsConfiguration configuration = new CorsConfiguration();

        configuration.setAllowedOriginPatterns(List.of(
            "https://example.com",
            "https://*.example.com",
            "https://anotherdomain.com"
        ));

        configuration.addAllowedMethod("*");
        configuration.addAllowedHeader("*");
        configuration.setAllowCredentials(true);

        UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
        source.registerCorsConfiguration("/**", configuration);
        return source;
    }
}

🔥 최종 정리

JWT 인증 필터 (JwtAuthenticationFilter) 적용
XSS 방어 필터 (XssFilter) 적용
CORS 설정 (corsConfigurationSource()) 적용
Spring Security에서 authorizeHttpRequests()를 사용한 권한 관리 적용


✅ Spring Security의 SecurityFilterChain에서 권한(Role) 정보는 어떻게 전달될까?

Spring Security에서 사용자의 권한(Role) 정보는 Authentication 객체를 통해 관리되며, SecurityContext에 저장됨.
이 권한 정보는 JWT 토큰 기반 인증을 사용할 경우, JWT에서 추출하여 SecurityContext에 저장하는 방식으로 전달됩니다.


1. SecurityFilterChain에서 권한 정보가 처리되는 흐름

[클라이언트 요청] 
    → SecurityFilterChain 시작
        → JwtAuthenticationFilter 실행 (JWT에서 권한 정보 추출)
        → SecurityContextHolder에 Authentication 저장
        → SecurityContextPersistenceFilter (SecurityContext 유지)
        → AuthorizationFilter (권한 검사)
    → 컨트롤러 실행
    → 응답 반환
[클라이언트 응답 받음]

JWT 기반 인증을 사용할 경우, JwtAuthenticationFilter에서 JWT에서 권한 정보를 추출하여 SecurityContext에 저장.
컨트롤러에서 @PreAuthorize, SecurityContextHolder 등을 사용하여 권한 기반 분기 처리 가능.


2. JwtAuthenticationFilter에서 권한 정보를 SecurityContext에 저장하는 방법

JWT에서 권한(Role) 정보 추출 및 SecurityContext 저장

import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.User;
import org.springframework.web.filter.OncePerRequestFilter;
import javax.servlet.*;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.util.Collections;
import java.util.List;
import java.util.stream.Collectors;

public class JwtAuthenticationFilter extends OncePerRequestFilter {

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

        if (token != null && token.startsWith("Bearer ")) {
            try {
                String username = JwtUtil.getUsername(token.substring(7)); // ✅ 사용자 이름 추출
                List<String> roles = JwtUtil.getRoles(token.substring(7)); // ✅ JWT에서 역할(Role) 추출

                // ✅ 역할 정보를 SecurityContext에 저장 role은 new SimpleGrantedAuthority("ROLE_"+claims.get("role")) 형태로 들어가야 된다.
                List<SimpleGrantedAuthority> authorities = roles.stream()
                        .map(SimpleGrantedAuthority::new)
                        .collect(Collectors.toList());

                User user = new User(username, "", authorities);
                UsernamePasswordAuthenticationToken usernamePasswordAuthenticationToken = new UsernamePasswordAuthenticationToken(user, null, authorities);
                SecurityContextHolder.getContext().setAuthentication(usernamePasswordAuthenticationToken);

            } catch (Exception e) {
                response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "Invalid Token");
                return;
            }
        }
        
        filterChain.doFilter(request, response);
    }
}

📌 JWT에서 getRoles() 메서드를 사용하여 권한 정보를 추출하고, SecurityContext에 저장.
📌 추출된 권한 정보를 SimpleGrantedAuthority로 변환하여 Spring Security의 권한 시스템에 맞게 적용.


3. JWT에서 권한 정보를 포함하는 방법 (JwtUtil.java)

JWT 생성 시 사용자의 역할(Role) 정보를 claim에 추가해야 합니다.

JWT 생성 시 역할(Role) 정보 포함

import io.jsonwebtoken.*;
import io.jsonwebtoken.security.Keys;
import java.util.Date;
import java.util.List;
import java.util.stream.Collectors;
import javax.crypto.SecretKey;

public class JwtUtil {

    private static final String SECRET_KEY = "mysecretkeymysecretkeymysecretkeymysecretkey"; // 32바이트 이상 필요
    private static final long EXPIRATION_TIME = 1000 * 60 * 60; // 1시간

    private static final SecretKey key = Keys.hmacShaKeyFor(SECRET_KEY.getBytes());

    // ✅ JWT 생성 (권한 정보 포함)
    public static String generateToken(String username, List<String> roles) {
        return Jwts.builder()
                .setSubject(username)
                .claim("roles", roles) // ✅ 권한 정보 추가
                .setIssuedAt(new Date())
                .setExpiration(new Date(System.currentTimeMillis() + EXPIRATION_TIME))
                .signWith(key, SignatureAlgorithm.HS256)
                .compact();
    }

    // ✅ JWT에서 사용자 역할(Role) 정보 추출
    public static List<String> getRoles(String token) {
        Claims claims = Jwts.parserBuilder()
                .setSigningKey(key)
                .build()
                .parseClaimsJws(token)
                .getBody();

        return claims.get("roles", List.class);
    }
}

📌 JWT 생성 시 "roles" 클레임에 역할(Role) 정보를 포함하여 저장.
📌 JWT에서 역할 정보를 getRoles() 메서드를 통해 추출 가능.


4. Spring Security에서 권한 기반 접근 제어

SecurityFilterChain에서 권한별 접근 제한

@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
    http
        .cors(cors -> cors.configurationSource(corsConfigurationSource()))
        .csrf().disable()
        .authorizeHttpRequests(auth -> auth
            .requestMatchers("/admin/**").hasRole("ADMIN") // ✅ ADMIN 권한 필요
            .requestMatchers("/user/**").hasAnyRole("USER", "ADMIN") // ✅ USER 또는 ADMIN 가능
            .requestMatchers("/public/**").permitAll() // ✅ 누구나 접근 가능
            .anyRequest().authenticated()
        )
        .formLogin().disable();

    return http.build();
}

📌 SecurityFilterChain에서 hasRole("ADMIN")을 사용하여 특정 URL에 대한 접근을 제한.
📌 JWT에서 추출한 권한 정보를 SecurityContext에 저장하면 Spring Security에서 자동으로 검증 가능.


5. 컨트롤러에서 권한 기반 분기 처리

@PreAuthorize를 활용한 권한 검증

@EnableWebSecurity
@EnableMethodSecurity // ✅ 추가: @PreAuthorize 활성화
@Configuration
public class SecurityConfig {
    ...
}
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequestMapping("/api")
public class RoleController {

    @PreAuthorize("hasRole('ADMIN')")
    @GetMapping("/admin")
    public String adminAccess() {
        return "Welcome, Admin!";
    }

    @PreAuthorize("hasAnyRole('USER', 'ADMIN')")
    @GetMapping("/user")
    public String userAccess() {
        return "Welcome, User!";
    }

    @PreAuthorize("isAuthenticated()")
    @GetMapping("/profile")
    public String profile() {
        return "This is your profile.";
    }
}

📌 JWT에서 추출한 권한 정보가 SecurityContext에 저장되므로, @PreAuthorize를 통해 권한 검증 가능.
📌 hasRole('ADMIN')을 사용하여 ROLE_ADMIN을 가진 사용자만 실행 가능.


6. SecurityContext에서 직접 권한 정보 확인

SecurityContext에서 현재 사용자 권한 확인

import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequestMapping("/api")
public class SecurityContextController {

    @GetMapping("/dashboard")
    public String getDashboard() {
        Authentication authentication = SecurityContextHolder.getContext().getAuthentication();

        if (authentication.getAuthorities().contains(new SimpleGrantedAuthority("ROLE_ADMIN"))) {
            return "Admin Dashboard";
        }

        if (authentication.getAuthorities().contains(new SimpleGrantedAuthority("ROLE_USER"))) {
            return "User Dashboard";
        }

        return "Access Denied";
    }
}

📌 SecurityContext에서 현재 사용자의 권한을 가져와 직접 확인 후 분기 처리 가능.


🔥 최종 정리

JWT 생성 시 권한(Role) 정보를 claim에 포함하여 저장
JwtAuthenticationFilter에서 JWT에서 권한 정보를 추출하여 SecurityContext에 저장
SecurityFilterChain에서 authorizeHttpRequests()를 사용하여 권한 기반 접근 제어
컨트롤러에서는 @PreAuthorize 또는 SecurityContextHolder를 사용하여 권한 기반 분기 처리 가능

🚀 즉, Spring Security의 SecurityFilterChain에서는 JwtAuthenticationFilter를 통해 권한을 SecurityContext에 전달하고, 이후 Spring Security가 이를 기반으로 권한 검증을 수행함! 🎯


© 2023 Lee. All rights reserved.