
프로젝트를 진행할때 큰 지각없이 사용하던 @AuthenticationPrincipal 이라는 어노테이션이 있다
@PostMapping("/")
public ApiResponse<MemoDTO.MemoCreateResp> save(@RequestBody @Valid MemoDTO.MemoCreateDTO requestDTO,
@AuthenticationPrincipal UserDetails userDetails) {
MemoDTO.MemoCreateResp response = memoService.create(requestDTO, userDetails.getUsername());
return ApiResponse.onSuccess(response);
}
이런식으로 회원에 대한 정보를 매핑하기 위해 member에 대한 특정 정보를 파라미터로 받는 것이 아닌 다른 무언가를 통해서 회원을 식별하는 방법 으로 이해하고 있었다
하지만 이번 단풍톤을 진행하면서 부터는 프론트 분들에게 내가 만든 토큰을 어떻게 사용할지 사전에 공지를 해야할 것 같아, 내가 먼저 확실하게 이해를 해야겠다는 생각으로 이 글을 정리하게 되었다.
해당 코드는 로그인을 통과한 이용자에 대해 토큰을 생성하고 그 회원의 정보를 SecurityContextHolder
에 저장한다
@RequiredArgsConstructor
@Slf4j
public class JWTFilter extends OncePerRequestFilter{
private final JWTUtil jwtUtil;
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
// 헤더에서 Authorization 토큰을 꺼냄
String authorizationHeader = request.getHeader("Authorization");
// Authorization 헤더가 없거나 Bearer 스킴이 없으면 다음 필터로 이동
if (authorizationHeader == null || !authorizationHeader.startsWith("Bearer ")) {
filterChain.doFilter(request, response);
return;
}
// Bearer 뒤의 토큰을 추출
String accessToken = authorizationHeader.substring(7).trim();
// 토큰 만료 여부 확인
try {
log.info("토큰 있어서 검증 시작");
jwtUtil.isExpired(accessToken);
} catch (ExpiredJwtException e) {
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
response.getWriter().print("access token expired");
return;
}
// 토큰이 access인지 확인
String category = jwtUtil.getCategory(accessToken);
if (!category.equals("access")) {
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
response.getWriter().print("invalid access token");
return;
}
//토큰에서 username과 role 획득
String username = jwtUtil.getUsername(accessToken);
String roleString = jwtUtil.getRole(accessToken);
// String role을 Role enum으로 변환
Role role = Role.valueOf(roleString);
Member member = Member.builder()
.username(username)
.role(role)
.build();
//UserDetails에 회원 정보 객체 담기
CustomUserDetails customUserDetails = new CustomUserDetails(member);
//스프링 시큐리티 인증 토큰 생성
Authentication authToken = new UsernamePasswordAuthenticationToken(customUserDetails, null, customUserDetails.getAuthorities());
SecurityContextHolder.getContext().setAuthentication(authToken);
filterChain.doFilter(request, response);
}
}
여기서 SecurityContextHolder 에 대해서 약간의 이해가 필요한데,
회원의 정보를 저장한다는 개념에서
로그인 한 회원의 정보를 서버에서 관리한다
-> 세션을 유지한다
-> Stateless 하지 못한 설계
-> 틀린 설계!
라고 생각 할 수 있지만 이는 사실 잘못된 해석이다
SecurityContextHolder 는 엄밀히 말하자면 임시세션이다
여기에 저장된 회원정보는 한 번의 요청-응답이 완료되면 사라진다
즉, 한 번의 요청을 해결하기 위해서 필요한 회원정보만 잠시 담아두는 형태이기 때문에 세션이라고 볼 수 없다. 저장되는 위치도 LocalThread이고 이렇게 로그인을 구현할 시, 토큰을 매 요청 받아야한다는 점에서도 Stateful 하다고 볼 수 없다.
그럼 이제 다시 어노테이션으로 돌아와보자
@PostMapping("/")
public ApiResponse<MemoDTO.MemoCreateResp> save(@RequestBody @Valid MemoDTO.MemoCreateDTO requestDTO,
@AuthenticationPrincipal UserDetails userDetails) {
MemoDTO.MemoCreateResp response = memoService.create(requestDTO, userDetails.getUsername());
return ApiResponse.onSuccess(response);
}
어차피 검증은 회원 id같은 것으로 하는게 아닌, 토큰의 유효성으로 회원을 검증하기 때문에 서버 입장에서는 헤더를 통해 토큰만 받을 수 있다면 굳이 회원의 id를 파라미터로 받을 필요가 없다
앞서 설정한 것처럼 SecurityContextHolder에 데이터를 저장할때 UserDetails의 구현체인 CustomUserDetails를 이용하여 데이터를 저장하므로 불러올때도 똑같이 UserDetails를 통해서 데이터를 불러온다