Backend/SpringBoot

[Spring Security] JWT 사용 및 Filter 구성 예

Dean83 2025. 12. 15. 16:00

JWT를 이용할 경우, 다음의 흐름을 통해 구성 한다.

  • JWT 검증 필터 -> 로그인 + JWT 발급 (로그인 처리 필터) -> 인가 -> 예외처리
    • JWT 발급 및 검증 빈을 이용해 검증 -> 검증 성공시 토큰정보에서 유저 정보 추출
      • 추출된 유저정보를 토대로 UserDetailsService를 구현한 구현체와 Dto, Repository 등을 통해 실제 유저 정보 반환
      • 실제 유저 정보를 AuthenticationManager 를 통해 저장
    • 검증 실패시 이용자가 입력한 아이디 / 패스워드를 AuthenticationManager로 전달하여 검증 -> 성공시 토큰 발급
  • UsernamePasswordAuthenticationFilter 앞에 JwtAuthenticationFilter 삽입
  • 중복로그인 허용 등은 설정으로 할 수 없으므로, JWT 검증 필터에서 필요하다면 수동으로 구현해야 한다.

 

 

JWT를 사용하기 위해 다양한 라이브러리가 있는데, numbus-jose 라이브러리를 이용한 예이다. 또한, 단일키 관련 예 이다.

(jjwt 라이브러리가 사용은 가장 쉽다)

build.gradle 에 다음을 추가

    implementation 'org.springframework.boot:spring-boot-starter-security'
    implementation 'com.nimbusds:nimbus-jose-jwt:10.6'

 

application.yaml 에 항목 추가

...

jwt:
  secret: ${JWT_SECRET_KEY}
  expiration: 3600000
  issuer: "my-server"
  
...
  • secret 은 외부 관리해야 하며, 256bit 이상 되어야 한다.
  • 만료시간은 ms 기준이다.

JWTConfiguration 구성

  • 이 설정을 추가함으로서, 실제 토큰 생성시 secret 키 값 등 설정값을 활용할 수 있도록 한다. 
  • 이 부분을 따로 분리한 이유는, (실제 JWT 발급/인증 클래스에서 바로 값을 사용하지 않는 이유는) 기능분리를 통해 의존도를 낮추기 위함이다.
package config;

import lombok.Getter;
import lombok.Setter;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.context.annotation.Configuration;

@Getter
@Setter
@Configuration
//yaml 에서 jwt 항목을 찾아와서 읽어서 불러옴
@ConfigurationProperties(prefix = "jwt")
public class JwtProperties {
    private String secret;
    private Long expiration;
    private String issuer;


}
  • ConfigurationProperties 는 application.yaml 에서 해당 prefix에 맞는 항목을 가져와 반영해 준다.
    • 이 어노테이션을 이용하기 위해서는,  ConfigurationPropertiesScan 어노테이션을 붙여야만 한다
    • 혹은, Configuration 어노테이션 대신 Component 어노테이션을 이용한다.

 

JWT 토큰 생성 및 검증 코드 예

package utils;

import com.nimbusds.jose.JWSAlgorithm;
import com.nimbusds.jose.JWSHeader;
import com.nimbusds.jose.JWSSigner;
import com.nimbusds.jose.JWSVerifier;
import com.nimbusds.jose.crypto.MACSigner;
import com.nimbusds.jose.crypto.MACVerifier;
import com.nimbusds.jwt.JWTClaimsSet;
import com.nimbusds.jwt.SignedJWT;
import config.JwtProperties;
import jakarta.annotation.PostConstruct;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;

import java.nio.charset.StandardCharsets;
import java.util.Date;

@Component
@RequiredArgsConstructor
public class JwtTokenProvider
{
    private final JwtProperties jwt;
    private JWSSigner jwsSigner;
    private JWSVerifier jwsVerifier;
    private CustomUserDetailsService userDetailsService;

    @PostConstruct
    public void init()
    {
        try
        {
            byte[] keys = jwt.getSecret().getBytes(StandardCharsets.UTF_8);
            jwsSigner = new MACSigner(keys);
            jwsVerifier = new MACVerifier(keys);
        }
        catch (Exception e)
        {
            throw new RuntimeException(e);
        }
    }

    public String generateToken(String username, String role)
    {
        try
        {
            Date now = new Date();
            Date expirationTime = new Date(now.getTime() + jwt.getExpiration());
            JWTClaimsSet claimsSet = new JWTClaimsSet
                    .Builder()
                    .issuer(jwt.getIssuer())
                    .subject(username)
                    .issueTime(now)
                    .expirationTime(expirationTime)
                    .claim("role",role)  //private claim
                    .build();

            JWSHeader header = new JWSHeader(JWSAlgorithm.HS256);
            SignedJWT signedJWT = new SignedJWT(header, claimsSet);
            signedJWT.sign(jwsSigner);
            return signedJWT.serialize();
        }
        catch (Exception e)
        {
            throw new RuntimeException(e);
        }
    }

    public JWTClaimsSet parseToken(String token)
    {
        try
        {
            SignedJWT signedJWT = SignedJWT.parse(token);
            if(signedJWT.verify(jwsVerifier) == false)
                throw new RuntimeException("Invalid token");

            JWTClaimsSet set = signedJWT.getJWTClaimsSet();
            if(set.getExpirationTime() != null && set.getExpirationTime().before(new Date()))
                throw new RuntimeException("Token expired");

            return set;
        }
        catch (Exception e)
        {
            throw new RuntimeException(e);
        }
    }

    private Boolean validateToken(String token)
    {
        try
        {
            parseToken(token);
            return true;
        }
        catch (Exception e)
        {
            return false;
        }
    }
    
    public Authentication getAuthentication(String token) {
        JWTClaimsSet claims = parseToken(token);

        String username = claims.getSubject();

        CustomUserDetails userDetails =
            userDetailsService.loadUserByUsername(username);

        return new UsernamePasswordAuthenticationToken(
            userDetails,
            null,
            userDetails.getAuthorities()
        );
    }
}
  • JWTClaimSet과 header 를 통해 실제 token 을 생성하게 된다.
  • Claim 은 기본으로 권장되는 항목 외에 커스텀한 항목 (private) 을 추가 할 수 있다. 

 

UserDetailsService 예

@Service
@RequiredArgsConstructor
public class CustomUserDetailsService implements UserDetailsService {

    private final UserRepository userRepository;
    private final UserMapper userMapper;

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {

        User user = userRepository.findByUsername(username)
                .orElseThrow(() -> new UsernameNotFoundException(username));

        UserDto dto = userMapper.toDto(user);
        return new CustomUserDetails(dto, user.getPassword());
    }
}
  • 리턴하는 UserDetails 는 CustomUserDetails 클래스를 생성, 프레임워크에 있는 UserDetails를 implements 하여 별도로 구현한다.

JWT 의 커스텀 필터 예 

  • OncePerRequestFilter 를 상속받아 요청당 한번만 실행되도록 한다.
  • 요청으로 온 토큰을 검증하여, 토큰이 없다면 다음 필터를 실행한다. 
  • 토큰이 있다면, 해당 정보를 SecurityContextHolder 에 저장 하고 필터를 실행한다.
@Component
public class JwtAuthenticationFilter extends OncePerRequestFilter {

    private final JwtTokenProvider jwtTokenProvider;

    public JwtAuthenticationFilter(JwtTokenProvider jwtTokenProvider) {
        this.jwtTokenProvider = jwtTokenProvider;
    }

    @Override
    protected void doFilterInternal(
        HttpServletRequest request,
        HttpServletResponse response,
        FilterChain filterChain
    ) throws ServletException, IOException {

        String token = resolveToken(request);

        if (token != null && jwtTokenProvider.validateToken(token)) {
            Authentication authentication =
                jwtTokenProvider.getAuthentication(token);

            SecurityContextHolder.getContext()
                .setAuthentication(authentication);
        }

        filterChain.doFilter(request, response);
    }

    private String resolveToken(HttpServletRequest request) {
        String bearer = request.getHeader("Authorization");
        if (bearer != null && bearer.startsWith("Bearer ")) {
            return bearer.substring(7);
        }
        return null;
    }
}

 

로그인시 토큰 생성 부분

  • 토큰 검증 실패시, 로그인을 하게되는데, 해당 부분 필터를 커스터마이즈 한다. 
  • userNamePasswordAuthenticationFilter 를 그대로 쓰지 않고, 수정하여 사용한다. 
public class JwtLoginFilter extends UsernamePasswordAuthenticationFilter {

    private final AuthenticationManager authenticationManager;
    private final JwtTokenProvider jwtTokenProvider;
    private final ObjectMapper objectMapper;

  	public JwtLoginFilter(
        AuthenticationManager authenticationManager,
        JwtTokenProvider jwtTokenProvider,
        ObjectMapper objectMapper
    ) {
        this.authenticationManager = authenticationManager;
        this.jwtTokenProvider = jwtTokenProvider;
        this.objectMapper = objectMapper;

        setFilterProcessesUrl("/api/auth/login");
    }

    @Override
    public Authentication attemptAuthentication(
        HttpServletRequest request,
        HttpServletResponse response
    ) {

        try {
            LoginRequest loginRequest =
                objectMapper.readValue(request.getInputStream(), LoginRequest.class);

            UsernamePasswordAuthenticationToken authRequest =
                new UsernamePasswordAuthenticationToken(
                    loginRequest.getUsername(),
                    loginRequest.getPassword()
                );

            return authenticationManager.authenticate(authRequest);

        } catch (IOException e) {
            throw new AuthenticationServiceException("Invalid login request", e);
        }
    }

    @Override
    protected void successfulAuthentication(
        HttpServletRequest request,
        HttpServletResponse response,
        FilterChain chain,
        Authentication authResult
    ) throws IOException {

        String accessToken =
            jwtTokenProvider.createAccessToken(authResult);

        response.setStatus(HttpServletResponse.SC_OK);
        response.setContentType("application/json;charset=UTF-8");

        response.getWriter().write(
            objectMapper.writeValueAsString(
                Map.of("accessToken", accessToken)
            )
        );
    }

    @Override
    protected void unsuccessfulAuthentication(
        HttpServletRequest request,
        HttpServletResponse response,
        AuthenticationException failed
    ) throws IOException {

        response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
        response.setContentType("application/json;charset=UTF-8");

        response.getWriter().write(
            objectMapper.writeValueAsString(
                Map.of(
                    "error", "UNAUTHORIZED",
                    "message", failed.getMessage()
                )
            )
        );
    }
}
  • 만일 AccessToken 외에 RefreshToken 또한 생성하려 한다면, Cookie 에 담아 넘겨주면 된다. 관련 예는 다음과 같다. 
...

    String refreshToken = jwtTokenProvider.generateToken(user.getUsername(), authorities.get(1));

        Cookie cookie = new Cookie("REFRESH_TOKEN", refreshToken);
        cookie.setHttpOnly(true);
        cookie.setPath("/");
        cookie.setMaxAge(60 * 60 * 24 * 30 );
        response.addCookie(cookie);
        
...

 

SecurityFilterChain 부분

  • 최종적으로 필터 등록을 하는 예는 다음과 같다. 
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {

    private final JwtAuthenticationFilter jwtAuthenticationFilter;
    private final JwtTokenProvider jwtTokenProvider;
    private final AuthenticationConfiguration authConfig;


    /**
     * mvcMatcher 사용 시 반드시 필요
     */
    @Bean
    HandlerMappingIntrospector handlerMappingIntrospector() {
        return new HandlerMappingIntrospector();
    }

    @Bean
    SecurityFilterChain filterChain(
        HttpSecurity http,
        HandlerMappingIntrospector introspector
    ) throws Exception {

        AuthenticationManager authenticationManager =
            authConfig.getAuthenticationManager();

        JwtLoginFilter jwtLoginFilter =
            new JwtLoginFilter(authenticationManager, jwtTokenProvider);

        // mvcMatcher 빌더
        MvcRequestMatcher.Builder mvc =
            new MvcRequestMatcher.Builder(introspector);

        http
            // 기본 인증 방식 제거
            .formLogin(AbstractHttpConfigurer::disable)
            .httpBasic(AbstractHttpConfigurer::disable)
            .logout(AbstractHttpConfigurer::disable)

            // 세션, CSRF
            .csrf(AbstractHttpConfigurer::disable)
            .sessionManagement(sm ->
                sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
            )

            // 권한 설정
            .authorizeHttpRequests(auth -> auth
                .requestMatchers(
                    PathRequest.toStaticResources().atCommonLocations()
                ).permitAll()

                .requestMatchers("/login").permitAll()

                // 🔐 SpEL ① 본인 리소스 접근만 허용
                .requestMatchers("/users/{id}")
                .access(new WebExpressionAuthorizationManager(
                    "#id == authentication.principal.id"
                ))

                // 🔐 SpEL ② ADMIN + 계정 활성화 사용자만 허용
                .requestMatchers("/admin/reports/**")
                .access(new WebExpressionAuthorizationManager(
                    "hasRole('ADMIN') and authentication.principal.enabled"
                ))

                // ===============================
                // ✅ RegexRequestMatcher 예제
                // ===============================
                // /api/v1, /api/v2, /api/v3 ... 버전 API
                .requestMatchers(
                    new RegexRequestMatcher(
                        "^/api/v[0-9]+/.*",
                        null
                    )
                ).hasRole("USER")

                // ===============================
                // ✅ MvcRequestMatcher 예제
                // ===============================
                // Spring MVC 매핑 규칙 그대로 사용
                .requestMatchers(
                    mvc.pattern(HttpMethod.GET, "/orders/{orderId}")
                ).hasRole("USER")

                .requestMatchers(
                    mvc.pattern("/admin/**")
                ).hasRole("ADMIN")

                .anyRequest().authenticated()
            )

            // 필터 등록 (순서 중요)
            .addFilterBefore(
                jwtAuthenticationFilter,
                UsernamePasswordAuthenticationFilter.class
            )
            .addFilterAt(
                jwtLoginFilter,
                UsernamePasswordAuthenticationFilter.class
            );

        return http.build();
    }
    

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

}

 

  • SpEL (Spring Expression Language)를 이용할 경우, 복합 권한을 체크할 수 있다.
    • 이 경우에는 .access() 를 이용한다.
  • mvc.pattern 을 통해 실제 컨트롤러에 매핑된 url을 이용할 수 있다 (인자값 까지 포함된 경우)
  • regex 의 경우, url 경로에 v숫자 가 들어가 있는 경우에 활용 할 수 있다.