이번 프로젝트에서 JWT를 사용해 로그인 인증을 진행했다.
그리고 Controller
에서 이 인증정보를 사용하기 위해 여러가지 방법들을 시도해보고, 그 과정들과 내가 최종적으로 사용하게 된 방법에 대해 정리해봤다.
UsernamePasswordAuthenticationFilter
를 상속해 사용자의 인증정보로 인증을 시도하는 필터를 만들었다.
인증에 성공한다면 jwt를 내려주고, 실패한다면 401을 내려주었다.
// 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 을 내려주었다.
@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
만 뽑아 사용하는 쪽으로 진행했다.
유저정보를 필요로 하는 컨트롤러에@AuthenticationPrincipal
로 userDetails
를 받아 항상 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를-설정해보자