그라찌에 - Request Interceptor

류희수·2024년 9월 19일

회원정보 수정 로직에서는 일일히 헤더에 jwt를 하나하나 더 넣고 있었는데 이거 아무리 봐도 아닌 거 같다는 생각을 했다!
분명히 무언가 한곳에서 처리하는 것을 만드는게 좋을 것이라고 생각하였고 장바구니, 쿠폰 에서는 일단은 제외하고 그냥 개발하였다.
멘토님에게 여쭤보았고 Request Interceptor 방식을 사용하여 구현해보려고 한다.


Request Interceptor

Request Interceptor클라이언트로부터 서버로 들어오는 모든 HTTP 요청을 가로채서 특정 작업을 수행할 수 있는 필터 역할을 한다.
-> 즉 서비스나 컨트롤러 로직에 도달하기 전에 검증을 한다 (지금의 나에게 아주 필요한 로직!)

내가 생각한 로직은 다음과 같다.

  1. HTTP 요청이 서버에 도착하면, Spring의 요청 처리 흐름에서 가장 먼저 Request Interceptor가 실행.
  2. Interceptor에서 JWT 토큰 검증을 수행한 후, 요청이 유효한지 확인.
  3. 검증이 실패하면, 컨트롤러나 서비스 로직으로 요청을 넘기지 않고 곧바로 에러 응답을 반환 -> 여기서 404로 처리 .
  4. 검증이 성공하면, 요청이 컨트롤러 및 서비스 로직으로 전달되어 정상적인 흐름으로 처리!!.

Config

@Component
@RequiredArgsConstructor
public class JwtRequestInterceptor implements HandlerInterceptor {

    private final JwtUtil jwtUtil;

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        String token = resolveToken(request);
        if (token != null) {
            response.sendError(HttpServletResponse.SC_NOT_FOUND, "JWT 토큰이 없습니다.");
            return false;
        }
        try {
            jwtUtil.extractAllClaims(token); // 검증
        } catch (RuntimeException e) {
            response.sendError(HttpServletResponse.SC_NOT_FOUND, "유효하지 않은 JWT 토큰입니다: " + e.getMessage());
            return false;
        }
        // 유효한 토큰인 경우
        return true;
    }

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

JwtRequestInterceptor 를 만들어서 Authorization 칸에 Bearer + Jwt토큰이 들어가 있는 것을 확인하는 식으로 하였음.


SecurityContextHolder

SecurityContextHolder 를 적용하여 필요한 곳에서 사용자 정보를 참조할 수 있도록 구현해보려고 한다.

일단 UserDetails를 상속해서 받는 방향으로 가보려고 한다.

@Getter
@Setter
public class CustomUserDetails implements UserDetails {

    private Long id;
    private String userId;
    private String password;
    private Role role;

처음에는 이런식으로 새로운 Details에 필요한 것들만 뽑아서 사용하려고 했다. 하지만 구글링을 해보니 adapter패턴을 사용해서 상속받아서 사용하는 사람들이 많았다.

어댑터 패턴을 사용해서 UserDetails를 상속받을 때의 이점:

  1. 기술 독립성 유지: 도메인 객체는 특정 기술(SPRING SECURITY, JPA 등)에 의존하지 않게 된다. 이렇게 하면 도메인 객체를 비즈니스 로직 중심으로 유지할 수 있습니다. 실제로 도메인 객체는 비즈니스 로직을 표현하고, 기술적 세부사항(보안, DB 접근 등)에 대해 몰라야 한다!!

  2. 재사용성 증가: 도메인 객체를 기술적 종속성 없이 설계하면, 다른 컨텍스트에서 해당 객체를 재사용하기 쉽다. ->
    예를 들어, User 객체는 다른 서비스나 계층에서 보안과 상관없는 로직에서도 쉽게 재사용할 수 있다!
    (로그인이 필요가 없는 곳에서도!)

  3. 유연성: 어댑터 패턴을 사용하면 UserDetails 인터페이스에 맞춰서 보안 관련 로직을 처리할 수 있지만, 도메인 객체는 독립적으로 유지된다. 만약 다른 인증 방식이나 보안 라이브러리로 전환하고 싶다면 (추후에 0Auth를 이용하여 api로그인도 적용할 예정) , UserDetails에 대한 구현체만 변경하면 된다.

  4. 의존성 역전: 도메인 객체가 특정 프레임워크에 종속되지 않도록 설계하는 것은 의존성 역전 원칙 (DIP) 를 지키는 것이다. 즉, 고수준의 비즈니스 로직이 저수준의 기술적 세부사항에 종속되지 않도록 만들어 유지보수성을 높인다.
    (-> 아직 이부분은 확 와닫지는 않는다 ㅠㅅㅠ)


@Service
@RequiredArgsConstructor
public class CustomUserDetails implements UserDetailsService {

    private final UserRepository userRepository;

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        User user = userRepository.findByUserId(username).orElseThrow(() ->
                new UsernameNotFoundException("사용자가 존재하지 않습니다."));

        return new UserAdapter(user);
    }

}

Spring Security가 내부적으로 로그인 처리나 인증 과정을 진행할 때 자동으로 호출.
( 추후에 loadUserByUsername 을 long타입으로 커스텀하고 싶음)

작동 방식
1. 사용자가 로그인 시, 입력한 username과 password로 로그인 요청을 보냄.
2. Spring Security는 username을 CustomUserDetailsServiceloadUserByUsername 메서드를 통해 확인.
3. CustomUserDetailsService는 username으로 사용자 정보를 데이터베이스에서 조회해 반환.
4. 반환된 UserDetails 객체를 기반으로 Spring Security는 인증 처리를 하고, 인증이 성공하면 이후 요청을 처리할 수 있게 된다.

@RequiredArgsConstructor
public class UserAdapter implements UserDetails {

    private final User user;

    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        /**
         * 희수 (09.21):  권한 로직 여기서 처리 -> 추후 어드민 페이지 나오면 설정하기
         */


        return null;
    }

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

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

    @Override
    public boolean isAccountNonExpired() {
        return UserDetails.super.isAccountNonExpired();
    }

    @Override
    public boolean isAccountNonLocked() {
        return UserDetails.super.isAccountNonLocked();
    }

    @Override
    public boolean isCredentialsNonExpired() {
        return UserDetails.super.isCredentialsNonExpired();
    }

    @Override
    public boolean isEnabled() {
        return UserDetails.super.isEnabled();
    }

    // 혹시 UserAdapter가 아닌 원본 User 객체가 필요할 때 사용
    public User getUser() {
        return this.user;
    }
}

UserAdapter 설정


CartController 변경

일단 카트 컨트롤러에 공통적으로 있던 userId로 찾아오는 과정들을 전부 제외하고
Long userId = userAdapter.getUser().getId();
이 로직으로 분리하였음.

 @GetMapping("/items")
    public ResponseEntity<List<CartItemResponseDTO>> readCartItem(@RequestParam("userId") Long userId) {
        List<CartItemResponseDTO> cartItems = cartService.readCartItem(userId);
        return ResponseEntity.ok(cartItems);
    }

이렇게 param으로 조회했던 것들을

@RestController
@RequiredArgsConstructor
@RequestMapping("/cart")
public class CartController {

    @Autowired
    private final CartService cartService;


    @PostMapping("/add")
    public ResponseEntity<String> addProductCart(@RequestBody CartDTO cartDTO, @AuthenticationPrincipal UserAdapter userAdapter) {
        Long userId = getUserIdFromUserDetails(userAdapter);
        cartService.addProductToCart(userId, cartDTO.getProductId(), cartDTO.getQuantity());
        return ResponseEntity.ok("성공적으로 저장되었습니다!");
    }

    @GetMapping("/items")
    public ResponseEntity<List<CartItemResponseDTO>> readCartItem(@AuthenticationPrincipal UserAdapter userAdapter) {
        Long userId = getUserIdFromUserDetails(userAdapter);
        List<CartItemResponseDTO> cartItems = cartService.readCartItem(userId);
        return ResponseEntity.ok(cartItems);
    }

    @DeleteMapping("/deleteProduct")
    public ResponseEntity<?> deleteProduct(@AuthenticationPrincipal UserAdapter userAdapter, @RequestBody CartDeleteDTO cartDeleteDTO) {
        Long userId = getUserIdFromUserDetails(userAdapter);
        cartService.deleteCartItem(userId, cartDeleteDTO);
        return ResponseEntity.ok("성공적으로 삭제되었습니다.");
    }

    @DeleteMapping("deleteAll")
    public ResponseEntity<?> deleteAllProduct(@AuthenticationPrincipal UserAdapter userAdapter) {
        Long userId = getUserIdFromUserDetails(userAdapter);
        cartService.deleteAllCartItems(userId);
        return ResponseEntity.ok("성공적으로 삭제되었습니다.");
    }


    private Long getUserIdFromUserDetails(@AuthenticationPrincipal UserAdapter userAdapter) {
        return userAdapter.getUser().getId();
    }
}

getUserIdFromUserDetails 메소드를 만들어 인증과인가를 동시에 진행하였음


에러 수정


    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        String token = resolveToken(request);
        if (token != null) {
            response.sendError(HttpServletResponse.SC_NOT_FOUND, "JWT 토큰이 없습니다.");
            return false;
        }

여기에 정상적으로 토큰을 넣었음에도 404에러가 나는 문제를 발견!

   private String resolveToken(HttpServletRequest request) {
        String bearerToken = request.getHeader("Authorization");
        log.info("Authorization Header: {}", bearerToken); 

        if (bearerToken != null && bearerToken.startsWith("Bearer ")) {
            String token = bearerToken.substring(7);
            log.info("Extracted Token: {}", token); 
            return token;
        }
        return null;
    }

바로 로그를 찍었음에도
Authorization Header: Bearer eyJhbGciOiJIUzI1NiJ9.eyJl,,,,,,
Extracted Token: eyJhbGciOiJIUzI1NiJ9.e,,,,,
로 정상적으로 출력되고 있었다.

추가) ㅋㅋㅋㅋㅋㅋㅋㅋㅋ 안웃김.
if (token != n ull) { 이었음...... 토큰이 있으면 당연히 에러를 내고 있었다.......... 그래서 고치니까
Cannot invoke "Grazie.com.Grazie_Backend.Config.UserAdapter.getUser()" because "userAdapter" is null 라는 새로운 에러를 발견했음.

한참을 삽질하다가 천천히 코드를 하나씩 찾아가보기록 했음.

찾아가다 보니 이 부분에서 null이 되고 있는 것 같다고 추측 !!

후에 따라가보니 securityContextHolderStrategy 가 비어있는 느낌이 강하게 들었음.

this.securityContextHolderStrategy.getContext().getAuthentication(); 가 의미하는 값은 UserDetail 과 같아야한다.
좀 더 자세히 이야기하면 현재 스프링 시큐리티의 컨텍스트에서 인증된 사용자의 정보를 가져오게 된다. 반환되는 Authentication 객체는 현재 인증된 사용자의 정보를 포함하고 있으며, 이 객체는 일반적으로 UserDetails를 포함한다.


    public Authentication getAuthentication(String accessToken) {

        Claims claims = extractAllClaims(accessToken);

        String username = claims.getSubject();

        UserDetails userDetails = customUserDetails.loadUserByUsername(username);

        // 인증 객체 생성 (권한은 아직 admin이 미완성이라 일단 비워두었음) 
        return new UsernamePasswordAuthenticationToken(userDetails, accessToken, userDetails.getAuthorities());
    }

일단 Spring Security에서 사용자를 인증할 때 사용되는 인터페이스인 Authentication 타입의 인터페이스를 사용하였다.
클래스는 UsernamePasswordAuthenticationToken를 사용하였다.
(기본적으로 사용자의 username과 password를 포함하는 토큰이고 이 토큰은 사용자가 시스템에 로그인할 때 인증 정보를 저장하고, 인증 성공 시 인증 객체로 변환한다)

@Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        String token = resolveToken(request);
        if (token == null) {
            response.sendError(HttpServletResponse.SC_NOT_FOUND, "JWT 토큰이 없습니다.");
            return false;
        }
        try {
            jwtUtil.extractAllClaims(token); // 검증
            // 요 부분 추가 
            Authentication authentication = jwtUtil.getAuthentication(token);
            SecurityContextHolder.getContext().setAuthentication(authentication);
        } catch (RuntimeException e) {
            response.sendError(HttpServletResponse.SC_NOT_FOUND, "유효하지 않은 JWT 토큰입니다: " + e.getMessage());
            return false;
        }
        return true;
    }

jwtUtil에서 추출하고 Authentication 타입으로 가져와 객체를 생성하였다.
(UserDetails와 토큰, 그리고 권한 정보를 사용하여 UsernamePasswordAuthenticationToken 객체 생성)


Authentication 객체를 Spring SecuritySecurityContext에 저장하여, 현재 사용자가 인증된 상태임을 전역적으로 관리할 수 있도록 한다.
Authentication객체를 해당 SecurityContext에 설정한다.


loadUserByUsername 커스텀

하지만 404에러를 발견했고,,, jwt토큰은 분명히 존재 하는 것 같았다.

의도치 않게 처음에 만들어둔 회원정보(여기에는 jwt를 직접 param으로 넣게 되어있다)로 테스트를 해봐도 정상 작동한다.


또한 jwt추출을 해도 정상적으로 나오고 있었다...

몇일을 삽질하고 구글링을 해도 안될떄는 역시 냅다 코드를 보는것이다 그때.......!!!!!!!

jwtBuilder에서는 subject를 String 타입으로 제공하고 나도 그에 맞추어서 Long타입인 pk를 사용하기 위해 String으로 캐스팅해 사용하고 있었단 것이 생각났다.

  @Override
    public UserDetails loadUserByUsername(String userId) throws UsernameNotFoundException {
        User user = userRepository.findById(Long.parseLong(userId)).orElseThrow(() ->
                new UsernameNotFoundException("사용자가 존재하지 않습니다."));

        return new UserAdapter(user);
    }

Long.parseLong(userId)) 부분을 long타입으로 변경하니 해결되었다!!
이유자체는 허무했지만 그래도 찾아보는 과정에서 여러가지 희노애락을 느껴서... 재밌었다!!


profile
자바를자바

0개의 댓글