JwtFilter

이번 프로젝트에서는 스프링 시큐리티를 사용하기로 했다.

또한 회원가입/로그인 시 Jwt를 발급하고 쿠키로 Jwt를 받아서 인증을 하는 방식을 채택했다.

그래서 Jwt를 검증하는 필터를 만들 필요가 생겼다.

OncePerRequestFilter는 스프링 시큐리티에서 제공하는 필터가 아니다.

그래서 인증 로직이 시큐리티 컨텍스트에 어거지로 넣는 느낌이 들어서 이게 맞나 싶긴 하다.

하지만 시큐리티 필터들도 기본적으로는 org.springframework.web.filter 에서 파생되지 않을까 싶었고

시간이 부족하기 때문에 우선은 이렇게 구현하기로 했다.

시큐리티는 더 공부하면 더 좋은 방법을 찾을 수 있을지 모르겠다.


구현

JwtAuthFilter

OncePerRequestFilter를 상속받은 JwtAuthFilter를 만든다.

스프링 시큐리티와 따로 노는 느낌이 드는건 이 필터를 빈 등록하면 모든 요청에 대해 필터링을 한다.

시큐리티에서 승인한 URL까지 필터링을 하기 때문에 골치였다.

결국 ExcludeUrlsRequestMatcher를 따로 구현하고,  shouldNotFilter를 오버라이드 해서 필터링 하지 않는 URL들을 설정했다.

JwtUtils에서 Jwt를 검증하고, 만약 잘못된 요청이라면 예외를 던져서 catch 문에서 에러 메시지를 내려줬다.

public class JwtAuthFilter extends OncePerRequestFilter {

    private final JwtUtils jwtUtils;
    private final RequestMatcher excludeUrlsMatcher;

    public JwtAuthFilter(JwtUtils jwtUtils, RequestMatcher excludeUrlsMatcher) {
        this.jwtUtils = jwtUtils;
        this.excludeUrlsMatcher = excludeUrlsMatcher;
    }

    @Override
    protected boolean shouldNotFilter(HttpServletRequest request) {
        return excludeUrlsMatcher.matches(request);
    }

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
        Cookie[] cookies = request.getCookies();
        try {
            String jwt = jwtUtils.getJwtFromCookie(cookies);
            Authentication authentication = jwtUtils.getAuthentication(jwt);
            SecurityContextHolder.getContext().setAuthentication(authentication);

            filterChain.doFilter(request, response);
        } catch (BadCredentialsException e) {
            response.setContentType(MediaType.APPLICATION_JSON_VALUE);
            response.setCharacterEncoding(UTF_8.name());
            response.setStatus(SC_UNAUTHORIZED);
            response.getWriter().write(e.getMessage());
        }
    }
}

ExcludeUrlsRequestMatcher

RequestMatcher를 구현하는 구현체를 만들어서 JwtAuthFilter에서 필터링 하지 않는 URL들을 지정했다.

public class ExcludeUrlsRequestMatcher implements RequestMatcher {

    private String[] excludeUrls;

    public ExcludeUrlsRequestMatcher(String... excludeUrls) {
        this.excludeUrls = excludeUrls;
    }

    @Override
    public boolean matches(HttpServletRequest request) {
        String requestURI = request.getRequestURI();
        AntPathMatcher pathMatcher = new AntPathMatcher();
        return Arrays.stream(excludeUrls)
            .anyMatch(excludeUrl -> pathMatcher.match(excludeUrl, requestURI));
    }
}

SecurityConfig

@Slf4j
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {

    private final ObjectMapper objectMapper;
    private final UserDetailsService userDetailsService;
    private final JwtUtils jwtUtils;

    @Bean
    public WebSecurityCustomizer webSecurityCustomizer() {
        return web -> web.ignoring()
            .requestMatchers("/favicon.ico")
            .requestMatchers("/error")
            .requestMatchers(toH2Console()); // TODO: 배포 시 제거
    }

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        return http
            .sessionManagement(session -> session.sessionCreationPolicy(STATELESS))
            .authorizeHttpRequests(authorize -> authorize               
                .requestMatchers("/**").permitAll()
            )
            .addFilterBefore(jwtAuthFilter(), UsernamePasswordAuthenticationFilter.class)
            .addFilterBefore(jsonAuthFilter(), UsernamePasswordAuthenticationFilter.class)
            .exceptionHandling(e -> {
                e.authenticationEntryPoint(new Http401Handler(objectMapper));
                e.accessDeniedHandler(new Http403Handler(objectMapper));
            })
            .csrf(AbstractHttpConfigurer::disable)
            .build();
    }

    @Bean
    public JwtAuthFilter jwtAuthFilter() {
        return new JwtAuthFilter(jwtUtils, new ExcludeUrlsRequestMatcher(
            "/api/auth/**",
            "/api/v1/auth/**",
            "/swagger-ui/**",
            "/swagger-resources/**",
            "/v3/api-docs/**"));
    }

    @Bean
    public JsonAuthFilter jsonAuthFilter() {
        JsonAuthFilter filter = new JsonAuthFilter("/api/auth/login", objectMapper);
        filter.setAuthenticationManager(authenticationManager());
        filter.setAuthenticationSuccessHandler(new LoginSuccessHandler(objectMapper, jwtUtils));
        filter.setAuthenticationFailureHandler(new LoginFailHandler(objectMapper));
        return filter;
    }

    @Bean
    public AuthenticationManager authenticationManager() {
        DaoAuthenticationProvider provider = new DaoAuthenticationProvider();
        provider.setUserDetailsService(userDetailsService);
        provider.setPasswordEncoder(passwordEncoder());
        return new ProviderManager(provider);
    }

    @Bean
    public PasswordEncoder passwordEncoder() {
        return new SCryptPasswordEncoder(
            16,
            8,
            1,
            32,
            64
        );
    }
}

결과

이렇게 해두면 ExcludeUrlsRequestMatcher 로 설정한 URL을 제외한 모든 요청에 대해 Jwt 인증을 수행한다.

그런데 스프링 시큐리티 세팅과 너무 따로 노는 느낌이 들어서 좋은 방법이 있다면 수정해보고 싶다.

문제 발견

아래와 같이 로그인/회원가입 구현 중 JWT를 발급하기 위해 시크릿 키를 만들던 도중 발생하였다.

@RequiredArgsConstructor
@ConfigurationProperties(prefix = "midcon")
public class AppConfig {

    private final String jwtKey;

    public SecretKey getSecretKey() {
        byte[] byteJwtKey = Decoders.BASE64.decode(this.jwtKey);
        return Keys.hmacShaKeyFor(byteJwtKey);
    }
}

 

분명 이 글을 쓰던 시점에서는 잘 됐으나 시크릿 키를 만드는 로직의 위치만 옮겼을 뿐인데 오류가 발생하였다.


문제 해결

결론부터 말하자면 예상 외로 라이브러리 버전 문제였다.

호돌맨님 인강을 보며 만들때는 문제가 없었지만 지금은 바뀐 조건이 많아서 뭐가 문제인지 확인하기 어렵다.

대충 생각 나는 원인은 아래 세 개 정도였다.

  • 로직 위치를 바꾼게 문제인지
  • 스프링부트 버전을 2.7대에서 3.2대로 올린게 문제인지
  • 자바 버전을 11에서 21로 올린게 문제인지

위 조건으로 로그를 찍어보면서 원인을 찾아봤지만 실패했다.

그래서 에러 메시지를 검색해보았고, 한 stackoverflow 글을 발견했다.

위 글을 참고하여 jwt 라이브러리를 0.12.1 버전으로 올리니 해결되었다.

검색을 잘 하자...

 

JWT 발급해보기

JWT 가 무엇인지는 이 글에 정리해두었으니 개념이 부족하다면 참고하자.

우선 스프링에서 JWT 를 쓰기 위해서는 JWT 라이브러리의 의존성을 추가해야한다.

build.gradle

// JWT
implementation 'io.jsonwebtoken:jjwt-api:0.12.0'
runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.12.0'
runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.12.0'

SecretKey 발급

아래와 같은 방법으로 SecretKey 를 발급할 수 있다.

하지만 실행할때마다 매번 바뀌기 때문에 한번 발급하고 출력하여 고정값으로 사용해야한다.

SecretKey 발급 로직만 추출해서 모듈화 하는것도 좋을것 같다.

 

SecretKey key = Jwts.SIG.HS256.key().build();
String strKey = Encoders.BASE64.encode(key.getEncoded());
log.info("secretKey = {}",strKey);

ㄴ Jwts.SIG.HS256.key().build()

SecretKey 를 생성한다.

ㄴ Encoders.BASE64.encode(key.getEncoded());

선택한 알고리즘으로 암호화된 SecretKey 를 BASE64로 인코딩한다.

 

SecretKey 가 위처럼 발급된걸 확인할 수 있다.

이 SecretKey 로 JWT 를 발급하므로 노출되지 않게 잘 관리하자.


JWT 발급

아래처럼 Jwt 빌더로 원하는 Claim 을 설정하면 위에서 발급한 SecretKey 로 JWT 를 발급한다.

Jwts.builder()
    .subject(name)
    .issuedAt(new Date())
    .signWith(secretKey)
    .compact());

 

위처럼 JWT가 잘 발급된것을 확인할 수 있다.

발급된 JWT를 검증하는 과정은 이 글을 참고하자.

'백엔드 > 연습' 카테고리의 다른 글

QueryDSL 적용하기: gradle-kotlin  (0) 2024.05.13
포트원 API 사용해보기  (0) 2024.04.04
plain-jar 파일 생성 방지  (0) 2024.01.16
QueryDSL 적용하기: gradle-groovy  (0) 2023.09.25
H2 DB 사용해보기  (0) 2023.09.24

+ Recent posts