Controller에서 SecurityContext의 유저 정보를 가져와보자

jhkim31·2024년 6월 17일
0

JSHOP 프로젝트

목록 보기
1/8

이번 프로젝트에서 JWT를 사용해 로그인 인증을 진행했다.

그리고 Controller 에서 이 인증정보를 사용하기 위해 여러가지 방법들을 시도해보고, 그 과정들과 내가 최종적으로 사용하게 된 방법에 대해 정리해봤다.

인증

UsernamePasswordAuthenticationFilter 를 상속해 사용자의 인증정보로 인증을 시도하는 필터를 만들었다.

인증에 성공한다면 jwt를 내려주고, 실패한다면 401을 내려주었다.

UsernamePasswordAuthenticationFilter.java

// attemptAuthentication() 중
UsernamePasswordAuthenticationToken authToken = new UsernamePasswordAuthenticationToken(
            email, password, null);
return authenticationManager.authenticate(authToken);
// successfulAuthentication() 중
response.setStatus(HttpServletResponse.SC_OK);
response.addHeader("Authorization", "Bearer " + token);
// unsuccessfulAuthentication() 중
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);

그리고 이 필터 앞에 OncePerRequestFilter를 상속한 JWT 검증 필터를 둬 인가작업을 진행했다.

인가

여기선 요청의 토큰을 검증해 유효한 토큰이라면 SecurityContext 를 설정해주고, 유효하지 않은경우 401 을 내려주었다.

JwtFilter.java

@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
    FilterChain filterChain) throws ServletException, IOException {
    Optional<String> optionalAuthorization = Optional.ofNullable(request.getHeader("Authorization"));

    // 토큰이 없을때
    if (!optionalAuthorization.isPresent()) {
        filterChain.doFilter(request, response);
        return;
    }

    // Bearer로 시작하지 않을때
    if (!optionalAuthorization.map(auth -> auth.startsWith("Bearer ")).orElse(false)) {
        response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
        Response responseDto = Response
            .builder().message("Authorizaation 헤더가 잘못되었습니다").error(ErrorCode.BAD_TOKEN).build();
        response.setContentType("application/json");
        response.setCharacterEncoding("UTF-8");
        response.getWriter().write(objectMapper.writeValueAsString(responseDto));
        return;
    }

    String token = optionalAuthorization.map(authorization -> authorization.substring(7)).get();

    if (jwtUtil.isExpired(token)) {
        response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
        Response responseDto = Response
            .builder().message("토큰이 만료되었습니다.").error(ErrorCode.TOKEN_EXPIRED).build();
        response.setContentType("application/json");
        response.setCharacterEncoding("UTF-8");
        response.getWriter().write(objectMapper.writeValueAsString(responseDto));
        return;
    }

    CustomUserDetails customUserDetails = CustomUserDetails
        .builder()
        .id(jwtUtil.getId(token))
        .username(jwtUtil.getEmail(token))
        .role(jwtUtil.getRole(token))
        .build();
    
    Authentication authToken = new UsernamePasswordAuthenticationToken(customUserDetails, null, customUserDetails.getAuthorities());
    SecurityContextHolder.getContext().setAuthentication(authToken);
    filterChain.doFilter(request, response);
}

그리고 요청마다 컨트롤러에서 SecurityContext 의 인증정보를 사용하기로 했다.

컨트롤러에서 SecurityContext 사용

처음에는 SecurityContextHolder 에 직접 접근해서 principal을 받아왔다.

SecurityContextHolder.getContext().getAuthentication().getPrincipal()

하지만 모든 컨트롤러에 이 로직을 추가하게 되니, 추후 변경이 생길때 유지보수가 불편할것 같았다.

방법을 찾던중 @AuthenticationPrincipal 라는 어노테이션에 대해 알게되었다.

현재 SecurityContext 에서 principal 만 제공해주는 어노테이션으로, Controller 파라미터에서 사용할 수 있었다.

(@AuthenticationPrincipal CustomUserDetails userDetails) 

이렇게 Principal 로 사용한 CustomUserDetails 를 바로 받을 수 있었다.

CustomUserDetails 에는 인증주체(사용자)의 id, username, email, 등 정보가 담겨있었다.

CustomUserDetails 사용시 예상되는 문제

처음에는 @AuthenticationPrincipal 를 사용하다 문득 한가지 문제가 떠올랐다.

만약 유저이름을 Service 레이어에서 사용하는데 사용자가 유저이름을 변경하게 된다면?

예를들어 사용자 이름이 old 인데 new 로 변경을 했다 가정해보자. 이때 jwt 토큰은 변함이 없으니, claim에도 변화가 없다.

하지만 @AuthenticationPrincipal 를 사용한다면 변경전 유저이름을 사용해 로직을 처리하게 될것이다.

old 를 가지고 CRUD가 발생하게 될 수 있었다.

이경우 일관성이 깨지는 문제가 발생할 수 있을것이다.

이러한 문제 때문에 사용자 정보를 직접 사용하는 CustomUserDetails 를 사용하지 않고, id 만 뽑아 사용하는 쪽으로 진행했다.

중복 코드 발생

유저정보를 필요로 하는 컨트롤러에@AuthenticationPrincipaluserDetails 를 받아 항상 getId() 를 하는 로직이 있었다.

중복 코드를 보기도 싫고 뭔가 일관되게 관리를 하고싶어, 방법을 고민해봤다.

SecurityContext 에서 Principal 만 뽑아주는게 가능하다면, Principal 에서 id 를 뽑아 제공하는것도 가능하지 않을까?? 싶었다.

@CurrentUserId 어노테이션 개발

우선 자바의 어노테이션은 어노테이션 자체로 어떤 기능이 있는것이 아니다.

즉 내가 만든 어노테이션이 붙은 파라미터에 @AuthenticationPrincipal 처럼 어떠한 기능이 동작하도록 하면 될것이다.

방법을 찾아보다가 HandlerMethodArgumentResolver 라는 인터페이스에 대해 알게되었다.

이 인터페이스는 컨트롤러 메서드에서 특정 조건에 맞는 파라미터에 값을 넣을때 사용하는 인터페이스다. @RequestBody 가 이러한 방식으로 RequestBody를 DTO로 매핑한다.

이 인터페이스를 구현해 인증정보에서 사용자 ID만을 리턴하는 어노테이션과 Resolver를 만들었다.

public class CurrentUserIdArgumentResolver implements HandlerMethodArgumentResolver {

    @Override
    public boolean supportsParameter(MethodParameter parameter) {
        return parameter.getParameterAnnotation(CurrentUserId.class) != null;
    }

    @Override
    public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer,
        NativeWebRequest webRequest, WebDataBinderFactory binderFactory) throws Exception {
        Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
        if (authentication != null && authentication.getPrincipal() instanceof CustomUserDetails) {
            CustomUserDetails userDetails = (CustomUserDetails) authentication.getPrincipal();
            return userDetails.getId();
        }
        throw new JwtUserNotFoundException();
    }
}

그리고 테스트를 진행하려고 했는데 테스트에서 SecurityContext 를 넣어줄 수 있는 방법이 필요했다.

https://velog.io/@jhkim31/Controller-단위-테스트에서-SecurityContext를-설정해보자

profile
김재현입니다.

0개의 댓글