[서버개발캠프] Spring boot + Spring security + Refresh JWT + Redis + JPA 2편

Sieun Sim·2020년 1월 17일
7

서버개발캠프

목록 보기
3/21

Spring security의 configure에 추가할 점.

JWT를 쓰려면 Spring Security에서 기본적으로 지원하는 Session 설정을 해제해야 한다. 또한 API 서버로 사용할거기때문에 CSRF 보안도 필요없어서 해제한다. 비인증시 로그인 페이지로 이동하는 것도 나는 React를 이용한 Single Page App에서 스프링으로 요청만 보낼것이기 때문에 해제했다.

Spring security는 기본적으로는 session base인것같다. WAS에 저장될 세션을 스프링 차원에서 관리할 수 있는듯하다. 서버 다중화는 트래픽 뿐만 아니라 한 서버가 죽었을때(?)를 대비해서도 필수라고 하는데, session은 한 서버에 하나씩이면 다른 서버끼리 공유할 수가 없으니 Redis같은데에 따로 저장하는 설정도 할 수 있다고 한다. 이 설정도 언젠가는 쓸 것 같으니 기억해두기

@Override
protected void configure(HttpSecurity http) throws Exception {
    http
        .httpBasic().disable() // rest api 이므로 기본설정 사용안함. 기본설정은 비인증시 로그인폼 화면으로 리다이렉트 된다.
        .csrf().disable() // rest api이므로 csrf 보안이 필요없으므로 disable처리.
        .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS) // jwt token으로 인증할것이므로 세션필요없으므로 생성안함.
        .and()
            .authorizeRequests() // 다음 리퀘스트에 대한 사용권한 체크
                .antMatchers("/*/signin", "/*/signup").permitAll() // 가입 및 인증 주소는 누구나 접근가능
                .antMatchers(HttpMethod.GET, "/exception/**", "helloworld/**").permitAll() // hellowworld로 시작하는 GET요청 리소스는 누구나 접근가능
            .anyRequest().hasRole("USER") // 그외 나머지 요청은 모두 인증된 회원만 접근 가능
        .and()
            .exceptionHandling().authenticationEntryPoint(new CustomAuthenticationEntryPoint())
        .and()
            .addFilterBefore(new JwtAuthenticationFilter(jwtTokenProvider), UsernamePasswordAuthenticationFilter.class); // jwt token 필터를 id/password 인증 필터 전에 넣어라.
 
}

스프링 시큐리티의 기본적인 설정들이 꽤나 대단히 많이 되어있다는것을 잊지말기

webpack때부터 느끼는거지만 기본설정에 익숙해지다보면 이 기능이 자체 설정되어 있는 것이고, 다르게 쓰기 위해서는 내가 바꿔줘야 한다는 사실조차 잊을 때가 많다. 이 세 줄이 내가 찾던 핵심인 것 같다. 나는 React로 frontend를 따로 구성해서 api server로만 쓸 건데 스프링시큐리티의 자동 로그인페이지랑 겹치는 문제를 어떻게 해결할지 고민하고 있었는데 그냥 설정을 해제하면 되는것이었다.....!!!

http
        .httpBasic().disable() // rest api 이므로 기본설정 사용안함. 기본설정은 비인증시 로그인폼 화면으로 리다이렉트 된다.
        .csrf().disable() // rest api이므로 csrf 보안이 필요없으므로 disable처리.
        .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS) // jwt token으로 인증할것이므로 세션필요없으므로 생성안함.

참고자료

이어지는 글이라 둘 다 봐야 한다.

db 끌어오기 전: https://www.javainuse.com/spring/boot-jwt

db 끌어오는 파트: https://www.javainuse.com/spring/boot-jwt-mysql

Generate JWT token

이 그림만 딱 봤을 때는 단순히 토큰이 오고가는 구조만 설명해주는 줄 알았는데 위의 네모칸에 들어있는것들이 모두 Custom Filter다. 원래 구현되어있는 filter들에 JWT 처리를 위해서 조금씩 커스터마이징 해주면 되는데 이해하고 다시 보니 그림을 참 잘만들었다. Jwt가 붙는것들을 사실 Jwt를 뺀 이름 (실제로 구현되어있는)의 필터들에 이 글의 필자가 커스터마이징해서 붙여준것으로 이해하면 된다.

AuthenticationManager가 하는 일

Spring Security는 자체적으로 User Class 정의해 두어서 내가 만들어서 쓸 Domain의 Class명을 User라고 이름지으면 헷갈려서 대재앙이 발생할수도 있다.. 그런데 밑에 사진에서는 또 그 User가 그 내장 User를 뜻하는건 아니긴하다. 아래 그림의 왼쪽에서 오는 화살표의 User는 비교할 (인풋으로 받았다든지) username과 password고 오른쪽은 JwtUserDetailsService가 내 db에서 가져올 username과 password다. AuthenticationManager.authenticate()로 간단하게 인증할 수 있다.

AuthenticationManager.authenticate(new UsernamePasswordAuthenticationToken(m.get("username"), m.get("password")));

이런식으로 썼을 때 UsernamePasswordAuthenticationToken은 내부적으로 UserDetailsService를 사용한다고 한다.(당장 겉으로 보이지는 않지만) 그리고 얘도 사실 Authentication 객체다.

Validating Token

나는 Redis로 토큰을 캐시해서 사용할거라서 5번에서 userDetails를 이용해 내 user db인 mysql과 직접 비교하지 않고, 따로 만든 Redis repository에 있는지만 확인할 것이다.

--> 1/19 수정사항! 한번 발급한 토큰에 대해서는 의심하지 않기로 했다. sign한 키만 맞다면 무조건 안전한 access라고 취급하고 인증할 것이다. 매번 userDetails에서 mysql userdb와 일치하는지 확인하는것도 db접근비용이 매번 드니까 하지 않기로 했다. Jwt+Spring Security의 예제에서는 거의 대부분 매 요청마다 필터에서 userDatails까지 확인을 하던데.. Jwt에 대해 알아본 뒤 바뀐 생각으로는 그렇게 매번 검사하는것 자체가 토큰을 만든 메인 목적과 어긋나는 것 같다. 나는 대신에 한번 발행한 토큰에 대해 validate의 조건을 1.expired 되지 않았음 2. 변조되지 않았음(JWT의 3번째 부분인 sign으로 처리) 두 개만 걸고 토큰의 payload 속 username만을 가지고 동작하려고 한다. 대신에 토큰 자체를 탈취 당하는 경우를 대비해 refreshing token을 사용하기로 했다. 카카오 개발자 사이트(?)에서 답변했던 걸 우연히 봤는데 access token은 30분, refreshing은 더 길게 하는 것 같다. 우리 과제의 요구사항인 캐시를 나는 refreshing token을 저장하는데에 쓰려고 한다

Custom Filter 만들기.

기본적으로 스프링 시큐리티가 제공하는 Filter chain들이 있고, 내가 설정을 바꾸기 위해서는 해당 filter들을 implement나 override해서 사용하면 되는 듯 하다. username-password의 로그인 체계라든지 아주 구체적으로 구현되어있는 부분들이 있어서 블로거들이 커스터마이징한줄 알고 헤맸었는데 사실 userDetails 같은 filter들도 내부적으로 구현이 되어있는거란걸 깨닫고 놀랐다.

Jwt는 spring security 자체 차원에서는 지원되지 않는 듯하고 spring-security-jwt library가 있기는 한데 도저히 예제를 찾지 못하겠다.. 나는 Jwt를 spring security와 함께 사용하고 싶기 때문에 Spring session 설정을 해제하고 JwtFilter를 새로 커스터마이징해 만들어서 request들에 걸어 유저의 토큰을 확인하려고 한다. 토큰은 username과 함께 Redis에 저장해서 쓴다.

spring은 filter에서 spring config 설정 정보를 쉽게 처리하기 위한 GenericFilterBean을 제공한다.

Filter를 구현한 것과 동일하고 getFilterConfig()나 getEnvironment()를 제공해주는 정도이다.

public class SomeFilter extends GenericFilterBean {

    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
            throws IOException, ServletException {
        // TODO 전처리
        chain.doFilter(request, response);
        // TODO 후처리
    }

}

Filter를 중첩 호출한 경우 (의도치 않은 경우) 매번 Filter의 내용이 수행되는 것을 방지하기 위해 GenericFilterBean을 상속한 OncePerRequestFilter도 있다.

OncePerRequestFilter를 상속하여 구현한 경우 doFilter 대신 doFilterInternal 메서드를 구현하면 된다.

public class SomeFilter extends OncePerRequestFilter {

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
            throws ServletException, IOException {
        // TODO 전처리
        filterChain.doFilter(request, response);
        // TODO 후처리
    }

}

나는 JWT 토큰 검사를 한 Request당 한번만 검사하고 싶기때문에 OncePerRequestFilter를 상속할 것이다.

filter의 순서를 지정하고 싶은 경우 @Order annotation을 사용하면 된다.

@Bean으로 선언하여 사용할 때에도 filter class에 @Order를 사용해야 제대로 동작한다고 한다.

지정된 숫자가 클수록 안쪽에서 실행된다.

즉 filterChain.doFilter의 전처리는 숫자가 낮을수록 먼저 실행되고 후처리는 숫자가 높을수록 먼저 실행된다. 지정할 숫자는 최소 -105 보다 큰 숫자를 사용하는 것을 권장한다.

이유는

custom filter를 등록하는 경우 위 filter의 처리를 거치고 난 이후의 수행을 사용하는 경우가 대부분이고 filter에서 만약 RequestContextHolder를 호출하는 구간이 있다면 필히 -105보다 순서를 상위로 지정해야 한다.

출처: Spring Boot servlet filter 사용하기

JwtRequestFilter 만들기

그럼 이제 Jwt 토큰 인증 자체를 Servlet으로 가기 전 filter에서 하고 싶으니 OncePerRequestFilter를 통해 한번의 리퀘스트마다 필터링을 해보자. 결국 이 단계에서 Client의 Request를 분석해 얘가 토큰을 보냈는지 확인하는 것이다. 원래의 예제에서는 userDetails를 사용해 내 mysql user DB까지 가서 찾아내지만 나는 redis를 이용해 캐시해둔 토큰에서 확인할 것이다.
1/19 수정사항: redis 용도는 refreshing token으로

CORS의 제약사항!!(충격)

  1. GET, HEAD, POST 만 사용 가능하다.
  2. POST의 경우에는 다음과 같은 조건이 경우에만 사용가능하다.
    1) content-type이 application/x-www.form-urlencoded, multipart/form-data, text/plain의 경우에만 사용 가능하다.
    2) customer Header가 설정이 된 경우에는 사용 불가하다. (X-Modified etc...)
    ->내 경우 header에 token을 넣었더니 CORS가 걸렸다.
  3. Server에서 Access-Control-Allow-Origin 안에 허용여부를 결정해줘야지 된다.

큰 제약사항은 위 3가지지만, 세부적으로는 preflight 문제가 발생하게 된다. preflight란, POST로 외부 site를 call 할때, OPTIONS method를 이용해서 URL에 접근이 가능한지를 다시 한번 확인하는 절차를 거치게 된다. 이때, 주의할 점이 WWW에서 제약한 사항은 분명히 content-type이 application/xml, text/xml의 경우에만 preflight가 발생한다고 되어있으나, firefox나 chrome의 경우에는 text/plain, application/x-www-form-urlencoded, multipart/form-data 모두에서 prefligh가 발생하게 된다.

출처:

https://netframework.tistory.com/entry/Spring-Security를-이용한-CORS-적용-1

token을 request header에 보내는 과정에서 3번과 문제가 생긴 것 같다. 분명히 CORS 풀어뒀는데 왜 갑자기 난리일까 찾아봤더니 header에 authorization: token을 추가해서 그런거였다. 그래서 request마다 거치게 되는 jwtRequestFilter에서 파라미터로 받는 httpservletresponse 객체에 직접 cors를 allow header를 추가했다.

Redis 설정

redis client library 중 lettuce를 사용하기로 했다.

RedisTemplate 를 이용해서 실제 레디스를 스프링에서 사용하는데 중요한 것은 setKeySerializer(), setValueSerializer() 메소드들이다. 이 메소드를 빠트리면 실제 스프링에서 조회할 때는 값이 정상으로 보이지만 redis-cli로 보면 key값에 \xac\xed\x00\x05t\x00\x0 이런 값들이 붙는다.

RedisConfig 파일 설정

직렬화 작업을 통해 객체를 json형태로 유지한다.

package com.example.auth.config;


import com.example.auth.Domain.Token;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.serializer.Jackson2JsonRedisSerializer;
import org.springframework.data.redis.serializer.StringRedisSerializer;

@Configuration
public class RedisConfig {

    @Bean
    public RedisConnectionFactory redisConnectionFactory() {
        LettuceConnectionFactory lettuceConnectionFactory = new LettuceConnectionFactory();
        return lettuceConnectionFactory;
    }
    @Bean
    public RedisTemplate<String, Object> redisTemplate() {
        RedisTemplate<String, Object> redisTemplate = new RedisTemplate<>();
        redisTemplate.setConnectionFactory(redisConnectionFactory());
        redisTemplate.setKeySerializer(new StringRedisSerializer());

        //객체를 json 형태로 깨지지 않고 받기 위한 직렬화 작업
        redisTemplate.setValueSerializer(new Jackson2JsonRedisSerializer<>(Token.class));
        return redisTemplate;
    }
}

그러면 다음과 같이 저장된다. 저장하는 방법은 2가지가 생각나는데

  1. json을 문자열 형태로 넣어서 다시 json으로 꺼내쓰기
  2. class 형태로 그대로 넣은뒤 hgetall같은걸로 꺼내기

중에서 1번으로 했다.

1개의 댓글

comment-user-thumbnail
2021년 2월 25일

Thanks sharing nice article

답글 달기