[Spring Security] Filter 구현 (3)
이번 포스팅에서는 인증(Authentication)에 성공하면 호출되는 CustomAuthSuccessHandler와 인증이 실패했을 때 호출되는 CustomAuthFailureHandler를 구현하고, 인가(Authorization) 과정까지 마치려고 한다.
우선 구현하기에 앞서 인가 작업을 위해서 JWT를 도입하였으며, 여기서 사용되는 Refresh토큰 저장을 위해 Redis를 사용하게 되었다. 사전 준비 과정은 다른 포스팅에서 진행해주었다.
SpringBoot 3.1.x Redis 설정 (with JWT)
SpringBoot에서 인증(Authentication) 및 인가(Authorization) 필터를 구현하면서 JWT를 도입하였고, Refresh 토큰의 저장소로 Redis를 채택하여 구현하려고 한다. 1. build.gradle 내부 redis 및 JWT 의존성 추가 // Redis
bes99.tistory.com
1. CustomAuthSuccessHandler 클래스 생성
@RequiredArgsConstructor
public class CustomAuthSuccessHandler extends SavedRequestAwareAuthenticationSuccessHandler {
private final ObjectMapper objectMapper;
private final TokenService tokenService;
@Override
public void onAuthenticationSuccess(
HttpServletRequest request,
HttpServletResponse response,
Authentication authentication) throws IOException {
UserDto userDto = ((SecurityUserDetailsDto) authentication.getPrincipal()).getUserDto();
String accessToken = tokenService.generateJwtAccessToken(userDto);
String refreshToken = tokenService.generateJwyRefreshToken(userDto);
// DataResponse 객체에 토큰 정보 추가
DataResponse<Map<String, String>> dataResponse;
Map<String, String> tokens = new HashMap<>();
tokens.put("accessToken", accessToken);
tokens.put("refreshToken", refreshToken);
if (Objects.equals(userDto.userStatus(), "INACTIVE")) {
// 휴면 계정 처리
dataResponse = new DataResponse(ErrorCode.INACTIVE_USER);
} else {
// 활성 계정 처리
dataResponse = new DataResponse(tokens);
}
// 응답 설정 및 JSON으로 변환하여 반환
response.setStatus(dataResponse.getStatus());
response.setCharacterEncoding("UTF-8");
response.setContentType("application/json");
objectMapper.writeValue(response.getOutputStream(), dataResponse);
}
}
CustomAuthSuccessHandler 클래스는 스프링 시큐리티의 SavedRequestAwareAuthenticationSuccessHandler를 확장하여 사용자 정의 인증 성공 로직을 제공한다. 해당 클래스는 인증이 성공적으로 완료된 후에 호출된다
- tokenService: TokenService는 사용자에 대한 JWT 엑세스 토큰과 리프레시 토큰을 생성하는 데 사용된다
1. Authentication 객체로부터 사용자의 상세 정보(UserDto)를 추출한다.
2. tokenService를 사용하여 사용자에 대한 JWT 엑세스 토큰과 리프레시 토큰을 생성한다.
3. 토큰 정보를 DataResponse 객체에 포함시켜 클라이언트에 응답값으로 넘겨주기 위해 필드를 구성한다. 이때 사용자의 계정 상태에 따라 다른 응답을 생성하며 휴면 상태가 아닐 경우에만 토큰을 응답 객체에 구성한다.
4. 최종적으로, objectMapper를 사용하여 DataResponse 객체를 JSON으로 변환 후 반환한다. 이때 반환하는 필드는 DataResponse 객체와 JWT 엑세스 토큰 및 리프레시 토큰을 반환하게 된다.
2. CustomFailureHandle 클래스 생성
public class CustomAuthFailureHandler implements AuthenticationFailureHandler {
@Override
public void onAuthenticationFailure(
HttpServletRequest request, HttpServletResponse response, AuthenticationException exception)
throws IOException {
ErrorCode errorCode = determineErrorCode(exception);
DataResponse dataResponse = new DataResponse(errorCode);
// JSON으로 응답
response.setStatus(errorCode.getStatus());
response.setCharacterEncoding("UTF-8");
response.setContentType("application/json");
response.getWriter().write(new org.json.JSONObject(dataResponse).toString());
response.getWriter().flush();
}
private ErrorCode determineErrorCode(AuthenticationException exception) {
if (exception instanceof AuthenticationServiceException) {
return ErrorCode.AUTH_TOKEN_FAIL;
} else if (exception instanceof LockedException) {
return ErrorCode.FORBIDDEN_ERROR;
} else if (exception instanceof DisabledException) {
return ErrorCode.INACTIVE_USER;
} else if (exception instanceof AccountExpiredException) {
return ErrorCode.NOT_FOUND_ERROR;
} else if (exception instanceof CredentialsExpiredException) {
return ErrorCode.AUTH_TOKEN_EXPIRED;
} else {
return ErrorCode.AUTH_TOKEN_NOT_MATCH;
}
}
}
CustomAuthFailureHandler 클래스는 스프링 시큐리티의 AuthenticationFailureHandler 인터페이스를 구현하여 사용자 정의 인증 실패 처리 로직을 제공한다. 이 클래스는 인증 프로세스가 실패했을 때 실행되며, 클라이언트에게 오류 메시지와 HTTP 상태 코드를 반환하는 역할을 수행한다.
1) onAuthenticationFailure() 메서드
- 이 메서드는 인증이 실패할 때 호출된다.
- AuthenticationException을 인자로 받아, 이를 기반으로 적절한 ErrorCode를 결정한다.
- 결정된 ErrorCode를 사용하여 DataResponse 객체를 생성한다.
- 응답의 상태 코드, 문자 인코딩, 컨텐츠 타입을 설정하고, DataResponse 객체를 JSON 형태로 클라이언트에 전송한다.
2) determineErrorCode() 메서드
- 발생한 AuthenticationException의 유형에 따라 적절한 ErrorCode를 반환해주는 메서드이다.
- 예외 유형에 따라 다른 ErrorCode를 매핑하여, 클라이언트가 인증 실패의 구체적인 원인을 이해할 수 있도록 구성하였다.
해당 클래스까지 작성을 완료했으면 인증(Authentication)에 대한 작업은 마치게 된다. 다음은 인가(Authorization)에 대한 작업을 진행하려고 한다.
3. JwtAuthorization 클래스 생성
@Slf4j
@RequiredArgsConstructor
public class JwtAuthorizationFilter extends OncePerRequestFilter {
private final CustomUserDetailsService customUserDetailsService;
private final TokenService tokenService;
private final ObjectMapper objectMapper;
@Override
protected void doFilterInternal(HttpServletRequest request,
@NonNull HttpServletResponse response,
@NonNull FilterChain filterChain
) throws ServletException, IOException {
// 1. 토큰이 필요하지 않은 API URL에 대해서 배열로 구성한다.
List<String> list = Arrays.asList(
"/api/users/login",
"/api/users/register"
);
// 2. 토큰이 필요하지 않은 API URL의 경우 -> 로직 처리없이 다음 필터로 이동한다.
if (list.contains(request.getRequestURI())) {
filterChain.doFilter(request, response);
return;
}
// 3. OPTIONS 요청일 경우 -> 로직 처리 없이 다음 필터로 이동
if (request.getMethod().equalsIgnoreCase("OPTIONS")) {
filterChain.doFilter(request, response);
return;
}
// 헤더에서 access 토큰 추출
String token = tokenService.getTokenFromRequest(request);
try {
// 토큰 유효성 체크
if (tokenService.isValidToken(token)) {
// 추출한 토큰을 기반으로 사용자 아이디를 반환받는다.
String email = tokenService.getUserEmailFromToken(token);
// 사용자 아이디가 존재하는지에 대한 여부를 체크한다.
if (email != null && !email.equalsIgnoreCase("")) {
UserDetails userDetails = customUserDetailsService.loadUserByUsername(email);
UsernamePasswordAuthenticationToken authentication =
new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());
SecurityContextHolder.getContext().setAuthentication(authentication);
filterChain.doFilter(request, response);
} else {
throw new ProfileApplicationException(ErrorCode.USER_NOT_FOUND);
}
}
// 토큰이 유효하지 않은 경우
else {
throw new ProfileApplicationException(ErrorCode.AUTH_TOKEN_INVALID);
}
} catch (Exception e) {
handleException(e, response);
}
}
private void handleException(Exception e, HttpServletResponse response) throws IOException {
ErrorCode errorCode = getErrorCode(e);
DataResponse<Object> errorResponse = new DataResponse<>(errorCode);
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
response.setCharacterEncoding("UTF-8");
response.setContentType("application/json");
objectMapper.writeValue(response.getOutputStream(), errorResponse);
}
private ErrorCode getErrorCode(Exception e) {
if (e instanceof ExpiredJwtException) {
return ErrorCode.AUTH_TOKEN_FAIL;
} else if (e instanceof SignatureException) {
return ErrorCode.AUTH_TOKEN_INVALID;
} else if (e instanceof JwtException) {
return ErrorCode.AUTH_TOKEN_NOT_MATCH;
} else {
return ErrorCode.AUTH_IS_NULL;
}
}
}
JwtAuthorizationFilter 클래스는 스프링 시큐리티의 OncePerRequestFilter를 확장하여 JWT 기반의 인증을 수행하는 필터를 정의한다. 해당 필터는 HTTP 요청이 들어올 때마다 한 번씩 실행되며, 요청에 포함된 JWT의 유효성을 검사하여 인가 작업을 수행한다. 위 코드는 다음과 같은 로직을 갖는다
1. 토큰 검증이 필요 없는 URL 처리: 일부 경로(로그인, 회원가입)에서는 토큰을 통한 검증이 필요하지 않음으로 제외 리스트를 생성한다. 해당 필터는 지정된 URL 리스트와 요청 URL을 비교하여, 해당 경로에 대한 요청이면 토큰 검증 없이 다음 필터로 요청을 넘기게 된다.
2. OPTIONS 메서드 처리: HTTP OPTIONS 메서드에 대한 요청은 일반적으로 CORS 사전 요청(pre-flight request)을 위해 사용된다. OPTIONS 요청에 대해서는 토큰 검증을 건너뛰고 다음 필터로 넘기게 된다.
3. 토큰 추출 및 검증: request 헤더에서 토큰을 추출하고 TokenService의 토큰 유효성 검사 메서드를 사용하여 토큰의 유효성을 검증한다. 토큰이 유효한 경우, 토큰에서 사용자의 이메일(또는 사용자 ID)을 추출한다.
4. 사용자 인증: 토큰에서 추출한 사용자 ID(email)를 사용하여 CustomUserDetailsService를 통해 사용자의 상세 정보(UserDetails)를 가져온다. 이 정보를 바탕으로 UsernamePasswordAuthenticationToken 객체를 생성하고, SecurityContextHolder에 설정하여 스프링 시큐리티의 보안 컨텍스트에 사용자가 인증되었음을 등록한다.
5. 예외 처리: 토큰 검증 과정에서 예외가 발생한 경우, handleException 메서드를 통해 오류 응답을 생성 후 반환한다.
6. 에러 코드 : getErrorCode 메서드는 발생한 예외 유형에 따라 적절한 ErrorCode를 결정하여 반환하게 된다.
4. 테스트
1) 로그인 테스트
인증 로직을 모두 구현 후 로그인 API를 성공적으로 호출하게 되면 다음과 같은 결과를 얻을 수 있다.
요청 성공에 대한 동작은 다음과 같다.
request -> CustomAuthenticationFilter -> AuthenticationManager -> CustomAuthenticationProvider -> CustomAuthSuccessHandler -> response
여기서 발급받은 엑세스 토큰을 통해 다른 작업을 추가적으로 수행할 수 있다. 토큰을 사용하기 위해서는 Bearer Token에 엑세스 토큰을 넣어 사용해주면 된다.
인증 작업에서 실패하게 되면 다음과 같은 응답을 반환받게 된다. 해당 예시는 일치하지 않는 비밀번호를 입력하였을 때 받는 반환값이다.
실패에 대한 동작은 다음과 같이 이루어진다.
request -> CustomAuthenticationFilter -> AuthenticationManager -> CustomAuthenticationProvider -> CustomAuthFailureHandler -> response
2) JWT 테스트
해당 테스트를 위해 인증작업이 완료된 후 토큰을 통해 사용자를 조회하는 로직을 간단하게 구현해보았다. 여기서는 /home 엔드포인트를 기입하면 request에서 토큰을 추출하여 사용자를 조회하는 로직으로 동작하게 된다.
2-1) UserController
@GetMapping("/home")
public DataResponse<UserResponse> home(HttpServletRequest request){
return new DataResponse<>(userService.home(request));
}
2-2) UserService
public UserResponse home(HttpServletRequest request){
String token = tokenService.getTokenFromRequest(request);
String email = tokenService.getUserEmailFromToken(token);
User user = userRepository.findByEmail(email).orElseThrow(() ->
new InvalidInputException(MessageUtils.INVALID_EMAIL_ID));
String diseaseName = "null";
if (user.getUserDisease() != null && user.getUserDisease().getDisease() != null) {
diseaseName = user.getUserDisease().getDisease().getDiscernment();
}
UserResponse userResponse = UserResponse.builder()
.name(user.getName())
.disease(diseaseName)
.build();
return userResponse;
}
2-3) 테스트 결과