인증 예외 변환기 구현
OAuth2 인증 과정에서 잘못된 사용자 이름이나 비밀번호를 입력하면 기본적으로 다음과 같은 형식의 응답이 반환됩니다:
{
"error": "invalid_grant",
"error_description": "Bad credentials"
}
grant_type이 잘못된 경우에는 다음과 같은 응답이 반환됩니다:
{
"error": "unsupported_grant_type",
"error_description": "Unsupported grant type: passwordd"
}
Spring Security에서는 WebResponseExceptionTranslator 인터페이스를 구현하여 이러한 인증 예외를 사용자 친화적인 형식으로 변환할 수 있습니다. translator 패키지에 SecurityResponseExceptionTranslator 클래스를 생성합니다.
@Slf4j
@Component
public class SecurityResponseExceptionTranslator implements WebResponseExceptionTranslator {
@Override
public ResponseEntity translate(Exception e) {
ResponseEntity.BodyBuilder status = ResponseEntity.status(HttpStatus.UNAUTHORIZED);
String message = "인증에 실패했습니다";
log.info(message, e);
if (e instanceof UnsupportedGrantTypeException) {
message = "지원하지 않는 인증 유형입니다";
return status.body(ResponseVO.failed(message + ": " + e.getMessage()));
}
if (e instanceof InvalidTokenException
&& StringUtils.containsIgnoreCase(e.getMessage(), "Invalid refresh token (expired)")) {
message = "리프레시 토큰이 만료되었습니다. 다시 로그인해주세요";
return status.body(ResponseVO.failed(message + ": " + e.getMessage()));
}
if (e instanceof InvalidScopeException) {
message = "유효하지 않은 scope 값입니다";
return status.body(ResponseVO.failed(message + ": " + e.getMessage()));
}
if (e instanceof InvalidGrantException) {
if (StringUtils.containsIgnoreCase(e.getMessage(), "Invalid refresh token")) {
message = "리프레시 토큰이 유효하지 않습니다";
return status.body(ResponseVO.failed(message + ": " + e.getMessage()));
}
if (StringUtils.containsIgnoreCase(e.getMessage(), "locked")) {
message = "계정이 잠겼습니다. 관리자에게 문의하세요";
return status.body(ResponseVO.failed(message + ": " + e.getMessage()));
}
message = "사용자 이름 또는 비밀번호가 일치하지 않습니다";
return status.body(ResponseVO.failed(message + ": " + e.getMessage()));
}
return status.body(ResponseVO.failed(message + ": " + e.getMessage()));
}
}
이 예외 변환기를 적용하려면 인증 서버 설정 클래스의 configure(AuthorizationServerEndpointsConfigurer endpoints) 메서드에서 이를 지정해야 합니다:
@Autowired
private SecurityResponseExceptionTranslator exceptionTranslator;
@Override
@SuppressWarnings("all")
public void configure(AuthorizationServerEndpointsConfigurer endpoints) {
endpoints.exceptionTranslator(exceptionTranslator);
}
리소스 서버 예외 처리
리소스 서버에서 발생할 수 있는 주요 예외는 두 가지입니다: 유효하지 않은 토큰(401)과 권한 부족(403).
먼저 401 Unauthorized 예외를 처리하는 SecurityExceptionEntryPoint 클래스를 생성합니다:
@Component
public class SecurityExceptionEntryPoint implements AuthenticationEntryPoint {
@Override
public void commence(HttpServletRequest request, HttpServletResponse response,
AuthenticationException authException) throws IOException {
ResponseVO.makeResponse(response,
MediaType.APPLICATION_JSON_VALUE,
HttpStatus.UNAUTHORIZED.value(),
JSONObject.toJSONString(ResponseVO.failed(401, "토큰이 유효하지 않습니다")).getBytes());
}
}
ResponseVO 유틸리티 클래스의 메서드는 다음과 같이 정의됩니다:
public static void makeResponse(HttpServletResponse response, String contentType,
int status, Object value) throws IOException {
response.setContentType(contentType);
response.setStatus(status);
response.getOutputStream().write(JSONObject.toJSONString(value).getBytes());
}
public static ResponseVO failed(Integer code, String msg) {
ResponseVO result = new ResponseVO();
result.setCode(code);
result.setMsg(msg);
result.setData(Lists.newArrayList());
return result;
}
403 Forbidden 예외를 처리하는 SecurityAccessDeniedHandler 클래스를 생성합니다:
@Component
public class SecurityAccessDeniedHandler implements AccessDeniedHandler {
@Override
public void handle(HttpServletRequest request, HttpServletResponse response,
AccessDeniedException accessDeniedException) throws IOException {
ResponseVO.makeResponse(response,
MediaType.APPLICATION_JSON_VALUE,
HttpStatus.FORBIDDEN.value(),
JSONObject.toJSONString(ResponseVO.failed(403, "해당 리소스에 접근할 권한이 없습니다")).getBytes());
}
}
리소스 서버 설정 클래스에서 이들을 주입하고 설정합니다:
@Autowired
private SecurityAccessDeniedHandler accessDeniedHandler;
@Autowired
private SecurityExceptionEntryPoint exceptionEntryPoint;
@Override
public void configure(ResourceServerSecurityConfigurer resources) {
resources.authenticationEntryPoint(exceptionEntryPoint)
.accessDeniedHandler(accessDeniedHandler);
}
모듈 재사용성 확장
여러 리소스 서버가 있는 경우, 위의 예외 처리 클래스를 공통 모듈(common)에 분리하여 재사용할 수 있습니다. 그러나 Spring Boot의 기본 스캔 범위는 메인 애플리케이션 클래스의 패키지와 그 하위 패키지로 제한되므로, @Component 애노테이션만으로는 다른 모듈의 빈이 등록되지 않습니다. 이 문제는 @Enable 모듈 드라이빙 방식을 사용하여 해결할 수 있습니다.
common 모듈의 configure 패키지에 SecurityExceptionConfigure 설정 클래스를 생성합니다:
public class SecurityExceptionConfigure {
@Bean
@ConditionalOnMissingBean(name = "accessDeniedHandler")
public SecurityAccessDeniedHandler accessDeniedHandler() {
return new SecurityAccessDeniedHandler();
}
@Bean
@ConditionalOnMissingBean(name = "authenticationEntryPoint")
public SecurityExceptionEntryPoint authenticationEntryPoint() {
return new SecurityExceptionEntryPoint();
}
}
@ConditionalOnMissingBean 애노테이션은 IOC 컨테이너에 지정된 이름 또는 타입의 빈이 없을 때만 빈을 등록합니다. 예를 들어, 리소스 서버 시스템에 accessDeniedHandler라는 이름의 빈이 없으면 SecurityAccessDeniedHandler가 빈으로 등록됩니다. 이를 통해 각 서브시스템이 자체 예외 처리기를 정의할 수 있습니다.
다음으로, 이 설정 클래스를 활성화하는 애노테이션을 정의합니다. common 모듈의 annotation 패키지에 EnableSecurityAuthExceptionHandler 애노테이션을 생성합니다:
@Target({ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Import(SecurityExceptionConfigure.class)
public @interface EnableSecurityAuthExceptionHandler {
}
이 애노테이션은 @Import를 사용하여 SecurityExceptionConfigure 설정 클래스를 포함시킵니다.
이제 리소스 서버 시스템의 메인 클래스에 @EnableSecurityAuthExceptionHandler를 추가하기만 하면 됩니다:
@SpringBootApplication
@EnableSecurityAuthExceptionHandler
public class Application {
public static void main(String[] args) {
SpringApplication.run(Application.class, args);
}
}
마지막으로, 리소스 서버 설정 클래스에서 자동 주입을 사용하여 SecurityAccessDeniedHandler와 SecurityExceptionEntryPoint를 주입할 수 있습니다:
@Autowired
private SecurityAccessDeniedHandler accessDeniedHandler;
@Autowired
private SecurityExceptionEntryPoint exceptionEntryPoint;
@Override
public void configure(ResourceServerSecurityConfigurer resources) {
resources.authenticationEntryPoint(exceptionEntryPoint)
.accessDeniedHandler(accessDeniedHandler);
}