OAuth 보안 예외 처리 가이드: 인증 오류 메시지 커스터마이징

인증 예외 변환기 구현

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);
    }
}

마지막으로, 리소스 서버 설정 클래스에서 자동 주입을 사용하여 SecurityAccessDeniedHandlerSecurityExceptionEntryPoint를 주입할 수 있습니다:

@Autowired
private SecurityAccessDeniedHandler accessDeniedHandler;
@Autowired
private SecurityExceptionEntryPoint exceptionEntryPoint;

@Override
public void configure(ResourceServerSecurityConfigurer resources) {
    resources.authenticationEntryPoint(exceptionEntryPoint)
            .accessDeniedHandler(accessDeniedHandler);
}

태그: Spring Security oauth2 WebResponseExceptionTranslator AuthenticationEntryPoint AccessDeniedHandler

6월 5일 22:58에 게시됨