지난 포스팅에서 AuthenticationManager
가 인증 완료된 Authentication 객체를 가져오고, JWT 토큰을 발급하는 단계까지 다뤘다.
인가 단계는 다음과 같은 과정을 가진다.
- 클라이언트에서 토큰을 포함한 API 요청.
- 인가 필터에서 토큰 유효성 검증.
- 검증 성공 시
SecurityContext
에Authentication
객체 저장
- access 토큰 만료시 토큰 재발급, 응답.
- 로그인 재요청 응답.
이제 커스텀 인가 필터를 통해 구현해보자.
인가 필터는 요청당 한 번만 실행되는 OncePerRequestFilter
를 상속하여 만들었으며 클라이언트의 API 요청시에만 실행될 예정이다.
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
throws IOException, ServletException {
// 헤더에 토큰 정보 확인
String header = request.getHeader(HEADER_STRING.getValue());
if (header == null || !header.startsWith(TOKEN_PREFIX.getValue())) {
chain.doFilter(request, response);
return;
}
// 헤더에서 토큰 정보 추출
String token = request.getHeader(HEADER_STRING.getValue()).replace(TOKEN_PREFIX.getValue(), "");
// 토큰 검증 및 인가
try {
securityService.validateToken(token);
// 인증 정보 추출
Authentication authentication = securityService.extractAuthentication(token);
// 사용자 인증 정보 저장
SecurityContextHolder.getContext().setAuthentication(authentication);
}
// access 토큰 만료시
catch (SecurityException e){
if (checkRefreshRequest(request, response, token)) {
return;
}
responseWriter.writeErrorResponse(response, SC_UNAUTHORIZED, e.getMessage());
}
// 나머지 예외 상황
catch (Exception e) {
responseWriter.writeErrorResponse(response, SC_UNAUTHORIZED, e.getMessage());
}
chain.doFilter(request, response);
}
요청 헤더에서 access 토큰 추출
// 헤더에 토큰 정보 확인
String header = request.getHeader(HEADER_STRING.getValue());
if (header == null || !header.startsWith(TOKEN_PREFIX.getValue())) {
chain.doFilter(request, response);
return;
}
// 헤더에서 토큰 정보 추출
String token = request.getHeader(HEADER_STRING.getValue()).replace(TOKEN_PREFIX.getValue(), "");
access 토큰 검증 성공 시
// 토큰 검증 및 인가 try { securityService.validateToken(token); // 인증 정보 추출 Authentication authentication = securityService.extractAuthentication(token); // 사용자 인증 정보 저장 SecurityContextHolder.getContext().setAuthentication(authentication); }
access 토큰 만료 시
// access 토큰 만료시 catch (SecurityException e){ if (checkRefreshRequest(request, response, token)) { return; } responseWriter.writeErrorResponse(response, SC_UNAUTHORIZED, e.getMessage()); }
토큰 재발급 요청일 때 처리
// 현재 요청이 토큰 재발급 요청인지
private boolean checkRefreshRequest(HttpServletRequest request, HttpServletResponse response, String token)
throws IOException {
String currentPath = request.getRequestURI();
if ("/api/security/refresh".equals(currentPath)) {
try {
AccessTokenResponse accessTokenResponse = securityService.refreshAccessToken(token);
responseWriter.writeAccessTokenResponse(response, accessTokenResponse);
return true;
// refresh 토큰 만료시
} catch (SecurityException e) {
responseWriter.writeErrorResponse(response, SC_UNAUTHORIZED, e.getMessage());
return true;
}
}
return false;
}
인가 필터에서는 인증 필터와는 다르게 별도로 AuthenticationManager을 필요로 하지 않는다.
// 인가 필터 설정
JwtAuthorizationFilter jwtAuthorizationFilter = new JwtAuthorizationFilter(securityService, responseWriter);
다만 필터 등록 시 등록 순서에 유의해야 한다.
.addFilterBefore(jwtAuthorizationFilter, UsernamePasswordAuthenticationFilter.class);
doFilterInternal
에서 토큰 검증 성공 시 아래의 코드를 통해 사용자 인증 정보 저장한다.
// 사용자 인증 정보 저장
SecurityContextHolder.getContext().setAuthentication(authentication);
SecurityContext
에 인증 객체를 저장하는 이유와 사용하는 방법에 대해 알아보자.
우선 SecurityContext
는 인증된 사용자의 세부 정보를 저장하는데 사용되며, 기본적으로 ThreadLocal
에 저장된다. 이는 각 요청마다 고유한 SecurityContext를 가지며, 다른 요청과 상태를 공유하지 않음을 뜻한다. 만약 요청을 처리하는 동안 사용자의 인증 상태가 변경되더라도 다른 요청에는 영향을 미치지 않는다.
SecurityContextHolder
를 통해 사용자 인증 정보를 저장하고, 가져올 수 있다(getter, setter 제공). 우리는 ThreadLocal의 특성에 따라 전역에서 SecurityContextHolder.getContext().getAuthentication()
를 통해 인증 정보를 가져올 수 있다.
나의 경우.. 사용자 정보가 필요한 Controller
메서드는 다음과 같이 사용하였다.
// 게시물 업로드
@PostMapping
public void uploadPost(@AuthenticationPrincipal(expression = "username") String username,
@Valid @ModelAttribute PostUploadRequest requestParam) {
postService.registerPost(username, requestParam);
}
아래 그림과 같이 Security에서 사용되는 많은 필터들이 있는데, 각각 보안 메커니즘을 잘 따져서 커스텀 필터를 구현하도록 해야 한다. 특히, JWT 로그인 같은 경우, 기준이 되는 reference가 없다고 생각되는데, 이 또한 잘 고려해서 설계해야함을 느끼는 바이다.