RefreshToken 도입 배경

이전 포스팅에서 세션/쿠키 방식의 로그인에 문제점들을 해결하고자 Spring Security를 이용한 JWT 로그인을 구현해 보았다.
하지만 JWT 통한 로그인 방식에는 토큰 탈취라는 보안상의 큰 문제가 있었다.
공격자는 탈취한 토큰을 통해 서버와 통신을할 수 있고 서버는 해당 요청이 사용자의 정당한 요청인지 공격자의 요청인지 구분할 수 없었다.
따라서 토큰에 유효시간을 두어 토큰이 탈취되더라도 일정 시간이 지나면 공격자가 서버와 통신할 수 없게 만들어야 했다.
하지만 이 경우 사용자도 토큰이 만료되는 시점에 다시 로그인을 해야되는 불편함이 생기고 이를 방지하기위해 RefreshToken를 도입하기로 했다.

RefreshToken 도입 후 로그인 전략

Refresh Token를 도입한 서비스의 사용자 인증절차는 다음과 같다.

  • 사용자가 로그인을 하면 AccessToken과 RefreshToken를 발급해준다.
  • 서버는 데이터베이스에 두 토큰을 한 쌍으로 저장한다.
  • 사용자는 인가가 필요한 요청에 대해 AccessToken를 헤더에 실어 보냄으로써 서버로부터 인가를 받는다.
  • AccessToken이 만료된경우 사용자는 RefreshToken를 통해 서버에게 토큰 재발급 요청을 한다.
  • 토큰 재발급 요청을 받은 서버는 AccessToken과 RefreshToken 각각 새로 만들어 새로운 한 쌍을 데이터베이스에 저장하고 사용자에게 발급한다.

AccessToken과 RefreshToken를 이런식으로 관리한다면 공격자에게 RefreshToken이 탈취당하더라도 다음과 같이 대처가능하다.

  • 공격자가 RefreshToken를 통해 AccessToken를 발급 받으려는 경우 AccessToken의 유효기간이 만료되지 않았다면 서버에서는 RefreshToken이 탈취되었다고 간주하고 데이터베이스에서 해당 쌍의 Token를 삭제한다.
    이 경우 공격자와 사용자 모두 재로그인이 요구된다.
  • 공격자가 RefreshToken를 통해 AccessToken를 발급 받으려는 경우 AccessToken의 유효기간 또한 만료되었다면 공격자는 새로운 쌍의 토큰을 발급받을 수 있지만 사용자가 해당 서비스를 이용하는 순간 서버는 토큰 탈취를 감지할 수 있게된다.

전체적인 토큰을 이용한 로그인 전략은 스택오버플로우의 아래 링크를 참고하였다.
https://stackoverflow.com/questions/32060478/is-a-refresh-token-really-necessary-when-using-jwt-token-authentication

구현 코드

RefreshToken의 도입 이유와 전략에 대한 개략적인 설명은 끝났으니 이제 구현된 코드를 살펴보자
이번 포스팅에서는 이전 포스팅과 비교해서 RefreshToken를 도입하는 과정에서 달라진 코드만 살펴보겠다.

본격적으로 코드를 살펴보기 전에 먼저 AccessToken과 RefreshToken를 한 쌍으로 관리하기 위해 만든 Entity를 보자.

@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class TokenPair {

    @Id @GeneratedValue
    private Long id;
    private String accessToken;
    private String refreshToken;

    @OneToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "user_id", unique = true)
    private User user;

    public static TokenPair createTokenPair(String accessToken, String refreshToken, User user) {
        TokenPair tokenPair = new TokenPair();
        tokenPair.accessToken = accessToken;
        tokenPair.refreshToken = refreshToken;
        tokenPair.user = user;
        return tokenPair;
    }

    public void updateToken(String accessToken, String refreshToken) {
        this.accessToken = accessToken;
        this.refreshToken = refreshToken;
    }
}

RefreshToken를 도입하면서 이전 포스팅과 달라진 부분은 크게 세 가지 이다.

  • Authentication 과정을 시큐리티 필터에서 MVC계층으로 옮김
  • 토큰의 효율적인 생산 및 관리를 위해 JwtUtils클래스 생성
  • refreshToken를 통한 토큰 갱신요청인 POST: /refresh 요청을 받는 controller 및 service 메소드 추가

먼저 AuthenticationFilter와 Authentication 성공 or 실패시 동작하는 AuthenticationSuccessHandlerCustom과 AuthenticationFailureHandlerCustom를 제거하고 추가한 코드를 살펴보자

@Slf4j
@RestController
@RequiredArgsConstructor
public class UserController {
    // ...
    @PostMapping("/login")
    public LoginResponse login(@Valid @RequestBody LoginRequest request) {
        return userService.login(request);
    }
    // ...
}
@Service
@Transactional
@RequiredArgsConstructor
public class UserService {
    private final JwtUtiles jwtUtiles;
    private final AuthenticationManager authenticationManager;
    private final TokenPairRepository tokenPairRepository;
    // ...
    public LoginResponse login(LoginRequest request) {
        // authenticationManager 이용한 아이디 및 비밀번호 확인
        Authentication authentication = authenticationManager.authenticate(
                new UsernamePasswordAuthenticationToken(request.getLoginId(), request.getPassword()));

        // 인증된 객체 생성
        PrincipalDetails principal = (PrincipalDetails) authentication.getPrincipal();

        // 인증 객체를 통해 tokenPair 생성
        String jwt = jwtUtils.createAccessToken(authentication);
        String refresh = jwtUtils.createRefreshToken(authentication);
        TokenPair tokenPair = TokenPair.createTokenPair(jwt, refresh, principal.getUser());

        // 기존 토큰이 있으면 수정, 없으면 생성
        tokenPairRepository.findByUserId(principal.getUser().getId())
                .ifPresentOrElse(
                        (findTokenPair) -> findTokenPair.updateToken(jwt, refresh),
                        () -> tokenPairRepository.save(tokenPair)
                );

        LoginResponse response = principal.getUser().getLoginInfo();
        response.setAccessToken(jwtUtils.addPrefix(jwt));
        response.setRefreshToken(jwtUtils.addPrefix(refresh));
        return response;
    }
    // ...
}

이렇게 수정한 이유는 Authentication 부분을 security 필터에서 MVC계층으로 옮겨 Controller와 Service에 나누어 구현하기 위해서였다.
이전부터 필터에서의 데이터베이스 조회 부분이 부자연스럽다고 생각했었지만, 단순 조회여서 transaction를 열지 않아도 되고 findById 수준의 조회는 성능 이슈에도 크게 영향을 미치지 않을것 같아 그대로 진행했었다.
하지만 RefreshToken를 도입함으로써 Authentication 과정에서 transaction를 열어 dirty checking를 통한 update나 save 등
높은 수준의 데이터베이스 상호작용이 요구되었기 때문에 해당 기능을 service 계층으로 옮기는것이 좀 더 자연스러워 보였다.
여기서 주의할 점은 authenticationManger.authenticate()를 통해 사용자 인증시 인증에 통과하지 못하면 예외를 던지게 되는데,
해당 예외는 AuthenticationEntryPointCustom에서 처리하기 때문에 해당 service에서는 신경쓰지 않아도 된다.

두 번째 큰 변화는 위 코드에서도 쓰인 JwtUtils 클래스의 추가이다. 코드는 아래와 같다.

@Component
@RequiredArgsConstructor
public class JwtUtils {

    // HMAC512의 시크릿키
    @Value("${...}")
    private String secretKey;

    // accessToken 유효시간
    @Value("${...}")
    private int jwtExpirationInMs;

    // refreshToken 유효시간
    @Value("${...}")
    private int refreshExpirationDateInMs;
    // accessToken 생성 메서드
    public String createAccessToken(Authentication authentication) {
        PrincipalDetails userDetails = (PrincipalDetails) authentication.getPrincipal();
        String token = JWT.create()
                .withSubject("ILuvIt_AccessToken")
                .withExpiresAt(new Date(System.currentTimeMillis() + jwtExpirationInMs * 1000L))
                .withClaim("id", userDetails.getUser().getId())
                .sign(Algorithm.HMAC512(secretKey));
        return token;
    }
    // refreshToken 생성 메서드
    public String createRefreshToken(Authentication authentication) {
        PrincipalDetails userDetails = (PrincipalDetails) authentication.getPrincipal();
        String token = JWT.create()
                .withSubject("ILuvIt_RefreshToken")
                .withExpiresAt(new Date(System.currentTimeMillis() + refreshExpirationDateInMs * 1000L))
                .withClaim("id", userDetails.getUser().getId())
                .sign(Algorithm.HMAC512(secretKey));
        return token;
    }
    // 토큰에서 사용자 id 추출
    public Long getUserIdFromToken(String token) {
        DecodedJWT jwt = JWT.require(Algorithm.HMAC512(secretKey)).build().verify(token);
        return jwt.getClaim("id").asLong();
    }
    // 토큰이 만료됐는지 check
    public Boolean isExpired(String token) {
        try {
            JWT.require(Algorithm.HMAC512(secretKey)).build().verify(token);
            return false;
        } catch (TokenExpiredException e) {
            log.warn("[JwtVerificationException] token 기간 만료 : {}", e.getMessage());
            return true;
        } catch (JWTVerificationException e) {
            log.warn("[JWTVerificationException] token 파싱 실패 : {}", e.getMessage());
            return false;
        }
    }
    // 토큰이 유효한지 check
    public void validateToken(String token) {
        try {
            JWT.require(Algorithm.HMAC512(secretKey)).build().verify(token);
        } catch (JWTVerificationException e) {
            log.warn("[JWTVerificationException] token 파싱 실패 : {}", e.getMessage());
            throw e;
        }
    }
    // 토큰 타입 명시
    public String addPrefix(String token) {
        return "Bearer " + token;
    }
}

JwtUtils의 각 메서드의 역할은 주석으로 명시되어있다. 해당 클래스는 @Component를 이용하여 bean으로 등록함으로써 다른 빈에게 주입할 수 있게끔 했다.

마지막으로 refreshToken를 갱신하는 부분이다.

// ...
public class UserController {
    // ...
    @PostMapping("/refresh")
    public LoginResponse refresh(@Valid @RequestBody TokenRefreshRequest request) throws IOException {
        LoginResponse response = userService.refresh(request);
        if (response != null) {
            return response;
        } else {
            throw new JWTVerificationException("유효하지 않은 시도입니다.");
        }
    }
    // ...
}
// ...
public class UserService {
    // ...
    public LoginResponse refresh(TokenRefreshRequest request) {

        String requestRefreshTokenToken = request.getRefreshToken().replace("Bearer ", "");

        // 요청으로 받은 refreshToken 유효한지 확인
        jwtUtils.validateToken(requestRefreshTokenToken);

        // 이전에 받았던 refreshToken과 일치하는지 확인(tokenPair 유저당 하나로 유지)
        Long userId = jwtUtils.getUserIdFromToken(requestRefreshTokenToken);
        TokenPair findTokenPair = tokenPairRepository.findByUserIdWithUser(userId)
                .orElseThrow(() -> new JWTVerificationException("유효하지 않은 토큰입니다."));

        if (!requestRefreshTokenToken.equals(findTokenPair.getRefreshToken())) {
            throw new JWTVerificationException("유효하지 않은 토큰입니다.");
        }

        // 이전에 발급했던 AccessToken 만료되지 않았다면 refreshToken 탈취로 판단
        // TokenPair 삭제 -> 다시 로그인 해야됨
        if (jwtUtils.isExpired(findTokenPair.getAccessToken())) {
            // refreshToken 유효하고, AccessToken 정상적으로 Expired 상태일때
            PrincipalDetails principal = new PrincipalDetails(findTokenPair.getUser());
            Authentication authentication = new UsernamePasswordAuthenticationToken(principal, null, principal.getAuthorities());

            String jwt = jwtUtils.createAccessToken(authentication);
            String refresh = jwtUtils.createRefreshToken(authentication);
            findTokenPair.updateToken(jwt, refresh);

            LoginResponse response = principal.getUser().getLoginInfo();
            response.setAccessToken(jwtUtils.addPrefix(jwt));
            response.setRefreshToken(jwtUtils.addPrefix(refresh));
            return response;

        } else {
            // accessToken이 아직 만료되지 않은 상태 -> 토큰 탈취로 판단 -> delete tokenPair
            tokenPairRepository.delete(findTokenPair);
            return null;
        }
    }
    // ...
}

refreshToken과 함께 POST /refresh 요청을 받으면 먼저 요청으로 온 refreshToken의 유효성을 검사한다.
유효성 검사에 통과하지 못한다면 예외를 던지고 해당 예외는 ControllerAdvice에서 ExceptionHandler를 통해 client에게 401에러(Unauthorized)를 응답으로 보낸다.
해당 절차는 데이터베이스의 tokenPair와 비교해서 불일치 할때도 마찬가지로 적용된다.
요청이 데이터베이스에 저장된 정보와도 일치하는지까지 확인했다면,
이제는 요청으로 온 refreshToken과 쌍을 이루는 accessToken의 유효시간이 만료되었는지 검사한다.
이때 아직 accessToken이 만료되지 않았다면 데이터베이스의 해당 tokenPair를 지우고 예외를 던져 사용자가 다시 로그인 하게끔 작동해야한다.
여기서 주의를 기울였던 부분은 만약 delete(findTokenPair) 다음에 예외를 바로 던지게 되면
transaction이 롤백되어 데이터베이스에 기존 정보가 남아있게 된다는 것이였다.
따라서 service계층에서는 accessToken이 만료된 경우에만 response를 return 하고 accessToken이 만료되지 않은경우
데이터베이스에 delete(findTokenPair)를 실행 한 후 null를 return하여 controller에서 예외를 던지게끔 설계하였다.

이상으로 spring security를 이용한 JWT 로그인에 RefreshToken 도입 과정이였다.

'Spring' 카테고리의 다른 글

[spring] spring security를 이용한 JWT 로그인  (0) 2022.07.28
[spring] 관심사의 분리  (0) 2022.06.14

 

기존에는 세션/쿠키 방식으로 로그인을 구현하였지만 다중 서버환경에서의 서버간 세션공유문제, 앱으로 확장했을때 clinet의 환경 차이,

Front-end에서 로컬환경으로 테스트할때 Back-end 서버와의 쿠키공유 불가 등 여러 문제점들로 인해 다른 방식의 로그인 구현이 요구되었다.

물론 다중 서버환경의 경우 세션클러스팅이나 세션키값을 DB에 저장하는 방식으로 문제 해결이 가능하고,

앱으로의 확장 문제도 client단에서 어느정도 해결가능한 문제였지만

직면한 문제들을 좀 더 효율적으로 해결할 수 있는 방법은 JWT를 기반으로 로그인을 구현하는 것이라 생각하였다.

따라서 JWT로그인을 구현하게 되었고 인증/인가에서 많은 부분 도움을 주는 spring security를 함께 사용하였다.

 

먼저 개략적인 흐름을 파악하기 위해서 SecurityConfig부터 살펴보자

@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {

    private final UserRepository userRepository;
    private final CorsConfig corsConfig;
    private final AuthenticationConfiguration authenticationConfiguration;
    private final AccessDeniedHandler accessDeniedHandler;
    private final AuthenticationEntryPoint authenticationEntryPoint;

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http.csrf().disable();
        http.addFilter(corsConfig.corsFilter())
                .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
                .and()
                .formLogin().disable()
                .httpBasic().disable()
                .addFilter(jwtAuthenticationFilter())
                .addFilter(jwtAuthorizationFilter())
                .addFilterBefore(exceptionHandlerFilter(), LogoutFilter.class)
                .exceptionHandling()
                .accessDeniedHandler(accessDeniedHandler)
                .authenticationEntryPoint(authenticationEntryPoint)
                .and()
                .authorizeRequests()
                .requestMatchers(CorsUtils::isPreFlightRequest).permitAll()
                .antMatchers("/user/**")
                .access("hasRole('PARENT') or hasRole('TEACHER') or hasRole('DIRECTOR')")
                .antMatchers("/parent/**")
                .access("hasRole('PARENT')")
                .anyRequest().permitAll();
        return http.build();
    }

    @Bean ExceptionHandlerFilter exceptionHandlerFilter() {
        return new ExceptionHandlerFilter();
    }

    @Bean
    public AuthenticationManager authenticationManagerBean() throws Exception {
        return authenticationConfiguration.getAuthenticationManager();
    }

    @Bean
    public AuthenticationSuccessHandler authenticationSuccessHandler() {
        return new CustomAuthenticationSuccessHandler();
    }

    @Bean
    public AuthenticationFailureHandler authenticationFailureHandler() {
        return new CustomAuthenticationFailureHandler();
    }

    @Bean
    public JwtAuthenticationFilter jwtAuthenticationFilter() throws Exception {
        JwtAuthenticationFilter jwtAuthenticationFilter = new JwtAuthenticationFilter(authenticationManagerBean());
        jwtAuthenticationFilter.setAuthenticationSuccessHandler(authenticationSuccessHandler());
        jwtAuthenticationFilter.setAuthenticationFailureHandler(authenticationFailureHandler());
        return jwtAuthenticationFilter;
    }

    @Bean
    public JwtAuthorizationFilter jwtAuthorizationFilter() throws Exception {
        return new JwtAuthorizationFilter(authenticationManagerBean(), userRepository);
    }
}

기존에는 WebSecurityConfigurerAdapter를 상속받아 SecurityConfig를 구현하였지만 최근에 WebSecurityConfigurerAdapter가 deprecated되면서

WebSecurityConfigurerAdapter의 메소드들을 override하는 방식이 아닌 @Bean을 통한 빈 등록을 이용해 구현할 수 있게 되었다.

 

먼저 SecurityFilterChain를 반환하는 filterChain메소드를 살펴보자

filterChain 메소드에서는 HttpSecurity를 통해 spring security의 각종 설정들을 할 수 있다.

 

http.csrf().disable();

csrf().disable()를 통해 SpringSecurity에 default로 적용되어있는 csrf protection를 해제한다.

이유는 현재 서버가 rest api 서버이고 인증정보로 JWT를 사용할 것이기 때문에 서버에 인증정보를 저장하지 않으므로 굳이 사용할 이유가 없다.

 

http.addFilter(corsConfig.corsFilter())

corsConfig.corsFilter를 추가하는 이유는 뒤에서 자세히 설명하겠지만 cors설정을 위한것이라고 생각하고 넘어가자

 

.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)

JWT를 사용할 것이기 때문에 STATELESS 즉, 세션을 사용하지 않겠다는 설정이다.

 

.formLogin().disable()
.httpBasic().disable()

formLogin().disable()은 Spring Security가 기본적으로 제공하는 formLogin 기능을 사용하지 않겠다는것을 나타낸다.

httpBasic().disable()은 매 요청마다 id, pwd를 보내는 방식으로 인증하는 httpBasic를 사용하지 않겠다는것을 나타낸다.

 

.addFilter(jwtAuthenticationFilter())
.addFilter(jwtAuthorizationFilter())

다음은 jwtAuthenticationFilter와 jwtAuthorizationFilter를 추가하는 부분이다.

두 필터 모두 Spring Security에서 제공하는 flter를 상속받아 구현한것으로 구현 코드는 뒤에서 자세히 다루도록 하겠다.

 

.addFilterBefore(exceptionHandlerFilter(), LogoutFilter.class)

exceptionHandelrFilter는 filter에서 터지는 exception들을 효율적으로 관리하기 위해 만든 filter이다.

ExceptionResolver를 호출하는 DispatcherServlet 앞단에 filter들이 위치하기 때문에

filter들의 exception들을 처리할 수 있는 별도의 장치가 필요해 보여 구현하게 되었다.

security에서 적용되는 filter중에 거의 제일 앞에 있는 LogoutFilter 앞에 적용시켜

filter에서 터지는 거의 모든 exception를 관리할 수 있도록 하였다.

(spring security를 지속적으로 공부하면서 알게된 사실이지만 인증/인가 수준에서 나올 수 있는 거의 모든 exception들을

처리하는 handler들이 security속에 다 구현돼 있어서 custom만 알맞게 한다면 직접 exceptionHandlerFilter를 만들 필요는 없는거 같다.)

 

.exceptionHandling()
.accessDeniedHandler(accessDeniedHandler)
.authenticationEntryPoint(authenticationEntryPoint)

인가 과정에서 JwtAuthorizationFilter가 JWT를 파싱하면서 토큰이 없거나 유효하지 않으면 authenticationEntryPoint에서,

권한 scope가 적절하지 않은 요청이면 AccessDeniedHandler에서 처리하도록 한다.

두 클래스 모두 security에서 제공하는 interface를 custom한 것이다.

 

.authorizeRequests()
.requestMatchers(CorsUtils::isPreFlightRequest).permitAll()
.antMatchers("/user/**")
.access("hasRole('PARENT') or hasRole('TEACHER') or hasRole('DIRECTOR')")
.antMatchers("/parent/**")
.access("hasRole('PARENT')")
.anyRequest().permitAll();

authorizeRequests() 밑으로는 어떤 요청들을 어떻게 처리할지 설정하는 부분이다.

requestMatchers(CorsUtils::isPreFlightRequest).permitAll() 부분은 PreFlight 요청에 대해 모두 허용하겠다는 설정이다.

.antMatchers("/user/**")
.access("hasRole('PARENT') or hasRole('TEACHER') or hasRole('DIRECTOR')")

이 부분은 "/user/"로 시작하는 모든 요청에 대해 PARENT 또는 TEACHER 또는 DIRECTOR 권한이 있어야만 응답하겠다는 설정이다.

 

filterChain 밑으로는 앞서 말한 직접 구현하는 filter들과 인증정보를 관리하는 AuthenticationManager의 빈 등록 내용이다.

 

 

다음으로 filter들을 구현하기전에 security에서 사용자 정보를 가져올때 사용하는 UserDetails와 그것을 반환하는

UserDetailsService를 구현한 내용을 보자.

@Data
@RequiredArgsConstructor
public class PrincipalDetails implements UserDetails {

    private final User user;

    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        Collection<GrantedAuthority> authorities = new ArrayList<>();
        authorities.add(() -> ("ROLE_" + user.getAuth().toString()));
        return authorities;
    }

    @Override
    public String getPassword() {
        return user.getPassword();
    }

    @Override
    public String getUsername() {
        return user.getLoginId();
    }

    @Override
    public boolean isAccountNonExpired() {
        return true;
    }

    @Override
    public boolean isAccountNonLocked() {
        return true;
    }

    @Override
    public boolean isCredentialsNonExpired() {
        return true;
    }

    @Override
    public boolean isEnabled() {
        return true;
    }

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;
        PrincipalDetails that = (PrincipalDetails) o;
        return Objects.equals(user, that.user);
    }

    @Override
    public int hashCode() {
        return Objects.hash(user);
    }
}

여기서 주의해야할 점은 spring security에서 권한을 읽을때 prefix로 "ROLE_"이 붙는다 가정하고 처리한다.

하지만 spring security를 떼야되는? 상황이 올 수도 있고 사용자들의 권한을 관리하는데 prefix로 무언가 붙는것이 부자연스러워서

spring security가 사용자의 권한을 읽어오는 getAuthorities메소드를 구현할때 prefix로 "ROLE_"를 붙이게끔 하였다.

@Service
@RequiredArgsConstructor
public class PrincipalDetailsService implements UserDetailsService {

    private final UserRepository userRepository;

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        // username이 loginId이다.
        User user = userRepository.findByLoginId(username)
                .orElseThrow(()->new UsernameNotFoundException("존재하지 않는 아이디입니다."));
        return new PrincipalDetails(user);
    }
}

 

이제 filter들의 구현내용들을 살펴보자

 

제일처음 살펴볼 filter는 JwtAuthenticationFilter이다.

해당 filter는 이름에서 알 수 있듯이 인증단계를 처리하는 필터로써 spring security에서 Authentication역할을 하는

UsernamePasswordAuthenticationFilter를 상속받아 구현하였다.

public class JwtAuthenticationFilter extends UsernamePasswordAuthenticationFilter {

    private final AuthenticationManager authenticationManager;

    public JwtAuthenticationFilter(AuthenticationManager authenticationManager) {
        super(authenticationManager);
        this.authenticationManager = authenticationManager;
    }

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

        try {
            ObjectMapper om = new ObjectMapper();
            LoginRequest loginInfo = om.readValue(request.getInputStream(), LoginRequest.class);

            UsernamePasswordAuthenticationToken authenticationToken =
                    new UsernamePasswordAuthenticationToken(loginInfo.getLoginId(), loginInfo.getPassword());

            return authenticationManager.authenticate(authenticationToken);

        } catch (IOException e) {
             e.printStackTrace();
        }
        return null;
    }

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

        PrincipalDetails principalDetails = (PrincipalDetails) authResult.getPrincipal();

        String jwtToken = JWT.create()
                .withSubject("ILuvIt")
                .withExpiresAt(new Date(System.currentTimeMillis() + (60000 * 60 * 1)))
                .withClaim("id", principalDetails.getUser().getId())
                .sign(Algorithm.HMAC512("secretKey"));
        response.addHeader("Authorization", "Bearer " + jwtToken);
        this.getSuccessHandler().onAuthenticationSuccess(request, response, authResult);
    }
}

이렇게 구현하게 되면 client가 POST: /login 요청을 했을때 attemptAuthentication메소드가 동작한다.

해당 메소드안에서는 ObjectMapper를 이용해 request에 담겨있는 사용자 정보를 DTO객체(loginRequest)로 만든다.

spring security에서 loginRequest정보를 이용할수 있게 token화 시키고 해당 token으로 authenticationManager를 이용하여 인증을 진행한다.

authenticationManager.authenticate 메소드는 PrincipaDetailsService에 loadByUsername 메소드를 호출하여

db정보와 요청 정보가 일치하는지 확인한다.

만약 두 정보가 일치하지 않아 인증에 실패한다면 AuthenticationFailureHandler로 이동하고,

인증에 성공한다면 Authentication(인증정보)객체를 return하면서 successfulAuthentication메소드를 거쳐 AuthenticationSuccessHandler로 이동하게 된다.

successfulAuthentication에서는 attemptAuthentication에서 return한 인증정보를 기반으로 PrincipalDetails를 생성하여 JWT를 만드는데 참조한다.

 

Authentication에 실패했을경우 실행되는 AuthenticationFailureHandler는 다음과 같다.

@Slf4j
public class AuthenticationFailureHandlerCustom implements AuthenticationFailureHandler {
    @Override
    public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) throws IOException, ServletException {
        log.warn("Authentication Error: {}", exception.getMessage());
        response.sendError(HttpServletResponse.SC_UNAUTHORIZED);
    }
}

단순히 서버에 로그를 남기고 Unauthorized(401) error를 client에게 반환한다.

 

다음은 Authentication에 성공했을경우 실행되는 AuthenticationSuccessHandler이다.

public class AuthenticationSuccessHandlerCustom implements AuthenticationSuccessHandler {

    @Override
    public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
        PrincipalDetails principalDetails = (PrincipalDetails) authentication.getPrincipal();
        LoginResponse loginResponse = principalDetails.getUser().getUserInfo();
        response.setContentType("application/json");
        response.getWriter().write(convertObjectToJson(loginResponse));
    }

    public String convertObjectToJson(Object object) throws JsonProcessingException {
        if (object == null) {
            return null;
        }
        ObjectMapper mapper = new ObjectMapper();
        return mapper.writeValueAsString(object);
    }
}

여기서는 로그인 요청에 성공했을때 client에게 알맞게 응답할 수 있도록 인증정보를 이용하여 response body를 만들었다.

(LoginResponse 객체는 client가 필요로 하는 정보를 담은 DTO이다. JWT payload에 해당 정보들을 담을 수 있지만 일단은 response body에 해당 정보를 담게끔 구현하였다.)

 

 

이제 Authorization단계를 설명할 차례이다.

@Slf4j
public class JwtAuthorizationFilter extends BasicAuthenticationFilter {

    private final UserRepository userRepository;
    private AuthenticationManager authenticationManager;

    public JwtAuthorizationFilter(AuthenticationManager authenticationManager, UserRepository userRepository) {
        super(authenticationManager);
        this.userRepository = userRepository;
    }

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

        String jwtHeader = request.getHeader("Authorization");

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

        try {
            String jwtToken = request.getHeader("Authorization").replace("Bearer ", "");
            DecodedJWT decodedJWT = JWT.require(Algorithm.HMAC512("secretKey")).build().verify(jwtToken);
            Long id = decodedJWT.getClaim("id").asLong();
            User user = userRepository.findById(id).
                    orElseThrow(()->new JWTVerificationException("유효하지 않은 토큰입니다."));

            PrincipalDetails principalDetails = new PrincipalDetails(user);
            Authentication authentication = new UsernamePasswordAuthenticationToken(
                    principalDetails, null, principalDetails.getAuthorities());

            SecurityContextHolder.getContext().setAuthentication(authentication);

        } catch (JWTVerificationException e) {
            log.warn("[JwtAuthorizationFilter] token 파싱 실패 : {}", e.getMessage());
        }
        chain.doFilter(request, response);
    }
}

Authorization을 위한 JwtAuthorizationFilter는 BasicAuthenticationFilter를 상속받아 구현하였다.

본래 BasicAuthenticationFilter는 httpBasic요청에 대한 인증 처리를 하는 필터이지만 securityConfig에서 disable하였기 때문에 상속받아 구현하여도 문제가 없어 보였다.

물론 필터를 직접 새로 만들어서 addFilter하여도 되지만 있는 필터를 상속받아 구현하는게 훨씬 수월할거라 판단하였다.

 

먼저 securityConfig에서 빈 주입을 위해 필요한 생성자를 알맞게 만들었다.

 

인가에 대한 전체적인 비즈니스 로직은 doFilterInternal에서 처리한다.

먼저 헤더에 발급했던 토큰이 있는지 확인한다. 만약 토큰이 없다면 별도의 처리없이 해당 필터를 넘긴다.

이 경우 해당 요청은 아무런 권한을 얻지 못하게 되고 만약 해당 요청에 특정 권한이 요구된다면 spring security의 다른 여러 필터들을 거쳐 결국 예외를 던져 AuthenticationEntryPoint에서 해당 예외를 처리하게 된다.

 

토큰이 존재한다면 해당 토큰을 확인하는 절차를 거친다.

DecodedJWT decodedJWT = JWT.require(Algorithm.HMAC512("secretKey")).build().verify(jwtToken);

만약 해당 토큰을 verify하는데 실패한다면 JWTVerificationException을 던지게 되고

해당 요청 역시 토큰이 존재하지 않는 경우와 똑같이 처리된다.

 

토큰이 유효한 경우 토큰을 이용하여 인증객체를 만들고 아래와 같이 시큐리티 세션에 인증객체를 저장한다.

SecurityContextHolder.getContext().setAuthentication(authentication);

세션에 인증정보를 저장한다면 Stateless하다는 JWT의 장점이 사라지는거 아닌가 물을 수 있겠지만

세션에 저장되는 인증정보는 해당 요청에 대한 권한 확인용으로만 사용되고

해당 요청이 완료되면 더 이상 사용되지 않고 사라지기 때문에 걱정하는 문제는 발생하지 않는다.

아무튼 spring security의 다른 필터들에서 시큐리티 세션에 저장된 인증정보와

securityConfig에서 설정한 각 요청들에 대한 필요 권한을 비교해보고

권한이 일치하지 않으면 예외를 던져 AccessDeniedHandler에서 처리하게 한다.

 

앞서 말한 검증들을 문제없이 통과한다면 해당 요청은 정상적으로 Controller까지 전달될 것이다.

 

AccessDeniedHanlder와 AuthenticationEntryPoint를 커스텀한 코드는 다음과 같다.

@Slf4j
@Component
public class AccessDeniedHandlerCustom implements AccessDeniedHandler {
    @Override
    public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException, ServletException {
        log.warn("[AccessDeniedHandlerCustom] Forbidden error : {}", accessDeniedException.getMessage());
        response.sendError(HttpServletResponse.SC_FORBIDDEN);
    }
}
@Slf4j
@Component
public class AuthenticationEntryPointCustom implements AuthenticationEntryPoint {
    @Override
    public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException {
        log.warn("[AuthenticationEntryPointCustom] Unauthorized error : {}", authException.getMessage());
        response.sendError(HttpServletResponse.SC_UNAUTHORIZED);
    }
}

참고로 상태코드 Unauthorized(401)는 이름에서 많이들 오해를 하지만 인가실패가 아닌 인증단계에서 에러가 있을때 주로 사용된다.

Forbidden(403)이 인가단계에서 실패하였을때 사용되는데 주로 사용자 권한의 scope와 요청에 필요한 권한이 일치하지 않을때 사용된다.

 

 

마지막으로 SecurityConfig에서 제일 먼저 addFilter한 CorsConfig의 corsFilter를 살펴보겠다.

(사실 이 부분에서 제일 많은시간을 삽질(?)했다...)

@Configuration
public class CorsConfig {

    public CorsFilter corsFilter() {
        UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
        CorsConfiguration config = new CorsConfiguration();
        config.setAllowCredentials(true);
        config.setAllowedOrigins(List.of("http://localhost:3000"));
        config.addAllowedHeader("*");
        config.addAllowedMethod("*");
        config.addExposedHeader("Authorization");


        source.registerCorsConfiguration("/**", config);
        return new CorsFilter(source);
    }

}

처음 SpringSecurity에서 Cors설정 부분을 보고 의아했던 사람도 있었을 것이다.

보통은 WebMvcConfigurer를 상속받아 구현하는 Configuration에서 addCorsMappings를 override 하는 방식으로 cors설정을 했기 때문이다.

하지만 SpringSecurity에서도 same-origin-policy를 검사하는 부분이 있어 보였고(사실 이부분을 디버깅하면서 찾아낼라 했지만 시간이 없어서 다음으로 미뤘다...) WebMvcConfigurer를 통해 cors설정을 하더라도 서블릿보다 앞단에 있는 필터에 해당 설정이 적용될리 만무했다.

따라서 SpringSecurity를 사용할 경우 CorsFilter를 통해 위 처럼 Cors설정을 따로 해줘야 했다.

 

 

 

개선해야할 점으로는 현재 AccessToken만료시 다시 로그인을 해야되는 문제가있다.

해당 문제는 추후에 RefreshToken 추가함으로써 개선할 계획이다.

 

 

 

 

 

관심사의 분리는 객체지향적 프로그래밍을 위한 매우 중요한 개념이다.

또한 관심사의 분리는 스프링의 탄생배경과도 연관이 있기 때문에 한번쯤 정리하고 넘어가는것이 좋을거 같다.

말보다는 코드를 보면서 설명하는것이 효율적일거 같다. 우선 관심사의 분리가 일어나지 않은 코드를 살펴보자.

 

Member 객체를 저장하거나 조회하기 위해 사용되는 MemberRepository 인터페이스와 그것을 구현하는 두 종류의 구현체가 있다.

여기서는 각 객체들의 관계를 중심으로 설명할 것이므로 비지니스 로직이 중요한것이 아니여서 구현 코드는 생략하였다.

public interface MemberRepository{
    // Member객체 저장
    void save(Member member);
    // memberId를 이용하여 Member객체 조회
    Member findById(Long memberId);
}
// in-memory 파일시스템을 기반으로 작동한다고 가정
public class MemroyMemberRepository implements MemberRepository{
    @Overrid
    public void save(Member member){
        ...
    }
    
    @Override
    public Member findById(Long memberId){
        ...
    }
}
// 데이터베이스를 기반으로 작동한다고 가정
public class DBMemberRepository implements MemberRepsoitory{
    @Override
    public void save(Member member){
        ...
    }
    
    @Override
    public Member findById(Long memberId){
        ...
    }
}

 

다음으로 MemberRepository를 이용하여 회원가입, 회원조회와 같은 서비스기능을 제공하는 MemberService 인터페이스와 그 구현체이다.

public interface MemberService{
    // 등록
    void join(Member member);
    // 조회
    Member findMember(Long memberId);
}
public class MemberServiceImpl implements MemberService{
    private final MemberRepository memberRepository = new MemoryMemberRepository();
    
    @Override
    public void join(Member member){
        ...
    }
    
    @Override
    public Member findMember(Long memberId){
        ...
    }
}

 

자 이제 본격적으로 관심사의 분리에 대해서 이야기를 해보자.

현재 MemberService의 구현체인 MemberServiceImpl은 MemoryMemberRepsitory를 구현체로 하는 MemberRepository를 사용한다.

여기서 만약 MemberService를 데이터베이스 환경으로 구현하고 싶다면 우리는 MemberServiceImpl의 코드를 다음과 같이 변경할 것이다.

public class MemberServiceImpl implements MemberService{
    // MemberRepository의 구현체를 DBMemberRepository로 바꿈
    private final MemberRepository memberRepository = new DBMemberRepository();
    
    @Override
    public void join(Member member){
        ...
    }
    
    @Override
    public Member findMember(Long memberId){
        ...
    }
}

 

무엇이 잘못되었는지 감이 오는가?

감이 잘 오지 않는다면 객체지향 설계 5원칙 SOLID중에서 SRP(단일 책임 원칙), OCP(개방-폐쇄 원칙), DIP(의존관계 역전 원칙)를 떠올려 보자.

 

SRP - "한 클래스는 하나의 책임만 가져야 한다"

현재 MemberServiceImpl은 의존 객체(MemberRepository)를 직접 생성하고 연결한뒤 로직을 구현하였다.

즉 "의존 객체의 생성", "연결", "로직 구현"이라는 3가지 책임을 동시에 맡고있다.  

 

OCP - "확장에는 열려 있으나 변경에는 닫혀 있어야 한다"

OCP 관점에서 우리는 interface를 통해 여러 구현체를 만듦으로써 확장을 할 수 있었다.

그렇다면 변경에는 닫혀 있는가?  대답부터 하자면 그렇지 않았다.

우리는 MemberServiceImpl에서 MemberRepository를 할당할때 상황이 달라질때마다 구현체를 직접 할당하는 방식으로 코드를 변경하여야 했다.

 

DIP - "추상화에 의존해야지, 구체화에 의존하면 안된다"

DIP관점에서 봤을때 MemberServiceImpl는 MemberRepository 인터페이스에 의존하는것 처럼 보이지만 실상은 그 구현체에 의존하고 있었다.

우리는 MemberServiceImpl를 구현할때 상황이 달라질때마다 MemoryMemberRepository를 직접 할당하기도, DBMemberRepository를 직접 할당하기도 했다.

 

이와 같은 현상이 벌어지는 이유는 기능구현의 책임을 가지고 있는 구현체가 의존관계 할당의 책임까지 맡고있기 때문이다.

따라서 이 같은 현상을 해결하기 위해서는 기능을 담당하는 구현체(MemberServiceImpl)는 역할구현에만 충실하고

서로의 의존관계를 배분하는, 즉 의존관계 주입을 담당하는 또 다른 무언가가 있어야 한다.

 

이를 위해서 우리는 client 외부에서 작동하는 의존관계 할당이라는 책임을 맡는 AppConfig라는 클래스를 만들어 보겠다.

public class AppConfig{
    public MemberService memberService(){
        return new MemberServiceImpl(memberRepository());
    }
    
    public MemberRepository memberRepository(){
        return new MemoryMemberRepository();
    }
}

 

AppConfig를 활용하여 MemberServiceImpl를 리팩토링한 코드는 다음과 같다.

public class MemberServiceImpl implements MemberService{
    private final MemberRepository memberRepository;
    
    // 생성자를 통하여 MemberRepository의 구현체를 주입 받는다.
    public MemberServiceImpl(MemberRepository memberRepository){
        this.memberRepository = memberRepository;
    }
    
    @Override
    public void join(Member member){
        ...
    }
    
    @Override
    public Member findMember(Long memberId){
        ...
    }
}

 

이제 우리는 AppConfig를 통해 외부에서 역할을 부여받음으로써 내부 구현체는 기능구현에만 책임을 갖도록 하였다.

이러한 구조는 기존에 위반되었던 객체지향 설계원칙을 다음과 같은 이유로 지킬 수 있게 되었다.

 

SRP (단일 책임 원칙)

구현 객체의 생성 및 의존관계 주입은 AppConfig가 담당하고 구현 객체는 해당 기능을 구현하는 책임만 담당함으로써 단일 책임 원칙을 지켰다.

 

OCP (개방-폐쇄 원칙)

MemberService를 예를 들면, 해당 인터페이스의 다른 구현체를 추가적으로 만드는것이 가능하므로 얼마든지 확장 가능하다.

또한 구현체를 확장해 나간다 하더라도 의존관계에 대한 관리는 AppConfig에서 하므로 구현체 입장에서의 변경은 닫혀있다.

 

DIP (의존관계 역전 원칙)

MemberServiceImpl이 MemberRepository의 구현체에 의존하던것을 MemberRepository자체 인터페이스에만 의존하도록 바꿨다.

대신에 AppConfig가 MemberRepository의 구현체를 생성하여 MemberServiceImpl에 주입할 수 있도록 설계하였다.

 

 

정리해보자면 우리는 기존에 구현체에서 의존관계에 있는 객체를 직접 생성하고 맡은 기능을 수행하는등 여러가지 책임을 가지고 있었다.

하지만 기존 방식은 객체지향적 프로그래밍 법칙에 많은 부분을 위반하고 있었고 이를 보완하기 위해 AppConfig라는 외부장치를 만들었다.

AppConfig는 구현 객체들을 생성하고 연결시켜 주는 책임을 맡음으로써 기존 구현체들이 구현의 책임만 맡을 수 있도록 관심사의 분리를 제공하였다.

 

이렇게 외부에서 AppConfig가 프로그램에 대한 제어 흐름 권한을 가지고 구현 객체들을 생성 및 실행하는 경우 이것을 제어의 역전(IoC)라고 한다.

또한 AppConfig가 런타임 시점에 실행된다면 애플리케이션은 런타임에 구현 객체를 생성하고 의존관계를 연결하는데 이것을 의존관계 주입(DI)라고 한다.

즉 AppConfig는 IoC와 DI의 효과를 일으킨다고 말할 수 있다.

 

앞에서 관심사의 분리가 스프링의 탄생 배경과도 연관 있다고 말한 이유가 이 IoC와 DI에 있다.

스프링이 탄생하게 된 큰 이유중에 하나가 바로 IoC와 DI를 개발자들에게 좀 더 편하게 제공하려는 것이기 때문이다.

 

 

 

 

+ Recent posts