@ExceptionHandler로 스프링 보안 인증 예외 처리


100

저는 Spring MVC를 사용 @ControllerAdvice하고 @ExceptionHandler있으며 REST Api의 모든 예외를 처리합니다. 웹 mvc 컨트롤러에서 throw 된 예외에 대해서는 잘 작동하지만 컨트롤러 메서드가 호출되기 전에 실행되기 때문에 스프링 보안 사용자 지정 필터에 의해 throw 된 예외에 대해서는 작동하지 않습니다.

토큰 기반 인증을 수행하는 사용자 지정 스프링 보안 필터가 있습니다.

public class AegisAuthenticationFilter extends GenericFilterBean {

...

    public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain) throws IOException, ServletException {

        try {

            ...         
        } catch(AuthenticationException authenticationException) {

            SecurityContextHolder.clearContext();
            authenticationEntryPoint.commence(request, response, authenticationException);

        }

    }

}

이 사용자 지정 진입 점 사용 :

@Component("restAuthenticationEntryPoint")
public class RestAuthenticationEntryPoint implements AuthenticationEntryPoint{

    public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authenticationException) throws IOException, ServletException {
        response.sendError(HttpServletResponse.SC_UNAUTHORIZED, authenticationException.getMessage());
    }

}

이 클래스를 사용하여 예외를 전역 적으로 처리합니다.

@ControllerAdvice
public class RestEntityResponseExceptionHandler extends ResponseEntityExceptionHandler {

    @ExceptionHandler({ InvalidTokenException.class, AuthenticationException.class })
    @ResponseStatus(value = HttpStatus.UNAUTHORIZED)
    @ResponseBody
    public RestError handleAuthenticationException(Exception ex) {

        int errorCode = AegisErrorCode.GenericAuthenticationError;
        if(ex instanceof AegisException) {
            errorCode = ((AegisException)ex).getCode();
        }

        RestError re = new RestError(
            HttpStatus.UNAUTHORIZED,
            errorCode, 
            "...",
            ex.getMessage());

        return re;
    }
}

내가해야 할 일은 스프링 보안 AuthenticationException에 대해서도 상세한 JSON 본문을 반환하는 것입니다. 스프링 보안 AuthenticationEntryPoint와 spring mvc @ExceptionHandler가 함께 작동하는 방법이 있습니까?

저는 스프링 보안 3.1.4와 스프링 mvc 3.2.4를 사용하고 있습니다.


9
당신은 할 수 없습니다 ... (@)ExceptionHandler요청이에서 처리되는 경우에만 작동합니다 DispatcherServlet. 그러나이 예외는 Filter. 따라서이 예외는 (@)ExceptionHandler.
M. Deinum 2013

네, 맞아요. EntryPoint의 response.sendError와 함께 json 본문을 반환하는 방법이 있습니까?
Nicola 2011

예외를 포착하고 그에 따라 반환하려면 체인의 앞부분에 사용자 지정 필터를 삽입해야하는 것 같습니다. 문서에는 필터, 별칭 및 적용 순서가 나열되어 있습니다. docs.spring.io/spring-security/site/docs/3.1.4.RELEASE/…
Romski

1
JSON이 필요한 유일한 위치 인 경우 EntryPoint. 거기에 객체를 만들고 거기에 삽입 할 수 MappingJackson2HttpMessageConverter있습니다.
M. Deinum 2013

@ M.Deinum 진입 점 안에 json을 빌드하려고합니다.
Nicola

답변:


62

좋아, AuthenticationEntryPoint에서 json을 직접 작성하는 것이 좋습니다.

테스트를 위해 response.sendError를 제거하여 AutenticationEntryPoint를 변경했습니다.

@Component("restAuthenticationEntryPoint")
public class RestAuthenticationEntryPoint implements AuthenticationEntryPoint{

    public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authenticationException) throws IOException, ServletException {

        response.setContentType("application/json");
        response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
        response.getOutputStream().println("{ \"error\": \"" + authenticationException.getMessage() + "\" }");

    }
}

이런 식으로 Spring Security AuthenticationEntryPoint를 사용하는 경우에도 승인되지 않은 401과 함께 사용자 정의 json 데이터를 보낼 수 있습니다.

분명히 테스트 목적으로 한 것처럼 json을 빌드하지는 않지만 일부 클래스 인스턴스를 직렬화합니다.


3
Jackson을 사용한 예제 : ObjectMapper mapper = new ObjectMapper (); mapper.writeValue (response.getOutputStream (), new FailResponse (401, authException.getLocalizedMessage (), "액세스 거부", ""));
Cyrusmith

1
질문이 조금 오래되었다는 것을 알고 있지만 AuthenticationEntryPoint를 SecurityConfig에 등록 했습니까?
leventunver

1
@leventunver 여기에서 진입 점을 등록하는 방법을 찾을 수 있습니다. stackoverflow.com/questions/24684806/… .
Nicola

37

이것은 Spring SecuritySpring Web 프레임 워크가 응답을 처리하는 방식에서 매우 일관 적이 지 않다는 매우 흥미로운 문제입니다 . MessageConverter편리한 방법 으로 오류 메시지 처리를 기본적으로 지원해야한다고 생각합니다 .

나는 MessageConverter그들이 예외를 포착하고 내용 협상에 따라 올바른 형식으로 반환 할 수 있도록 Spring Security 에 주입하는 우아한 방법을 찾으려고 노력 했다 . 그래도 아래 내 솔루션은 우아하지는 않지만 적어도 Spring 코드를 사용합니다.

Jackson과 JAXB 라이브러리를 포함하는 방법을 알고 있다고 가정합니다. 그렇지 않으면 진행할 필요가 없습니다. 총 3 단계가 있습니다.

1 단계-MessageConverters를 저장하는 독립 실행 형 클래스 만들기

이 클래스는 마술을하지 않습니다. 메시지 변환기와 프로세서 만 저장합니다 RequestResponseBodyMethodProcessor. 마법은 콘텐츠 협상을 포함한 모든 작업을 수행하고 그에 따라 응답 본문을 변환하는 프로세서 내부에 있습니다.

public class MessageProcessor { // Any name you like
    // List of HttpMessageConverter
    private List<HttpMessageConverter<?>> messageConverters;
    // under org.springframework.web.servlet.mvc.method.annotation
    private RequestResponseBodyMethodProcessor processor;

    /**
     * Below class name are copied from the framework.
     * (And yes, they are hard-coded, too)
     */
    private static final boolean jaxb2Present =
        ClassUtils.isPresent("javax.xml.bind.Binder", MessageProcessor.class.getClassLoader());

    private static final boolean jackson2Present =
        ClassUtils.isPresent("com.fasterxml.jackson.databind.ObjectMapper", MessageProcessor.class.getClassLoader()) &&
        ClassUtils.isPresent("com.fasterxml.jackson.core.JsonGenerator", MessageProcessor.class.getClassLoader());

    private static final boolean gsonPresent =
        ClassUtils.isPresent("com.google.gson.Gson", MessageProcessor.class.getClassLoader());

    public MessageProcessor() {
        this.messageConverters = new ArrayList<HttpMessageConverter<?>>();

        this.messageConverters.add(new ByteArrayHttpMessageConverter());
        this.messageConverters.add(new StringHttpMessageConverter());
        this.messageConverters.add(new ResourceHttpMessageConverter());
        this.messageConverters.add(new SourceHttpMessageConverter<Source>());
        this.messageConverters.add(new AllEncompassingFormHttpMessageConverter());

        if (jaxb2Present) {
            this.messageConverters.add(new Jaxb2RootElementHttpMessageConverter());
        }
        if (jackson2Present) {
            this.messageConverters.add(new MappingJackson2HttpMessageConverter());
        }
        else if (gsonPresent) {
            this.messageConverters.add(new GsonHttpMessageConverter());
        }

        processor = new RequestResponseBodyMethodProcessor(this.messageConverters);
    }

    /**
     * This method will convert the response body to the desire format.
     */
    public void handle(Object returnValue, HttpServletRequest request,
        HttpServletResponse response) throws Exception {
        ServletWebRequest nativeRequest = new ServletWebRequest(request, response);
        processor.handleReturnValue(returnValue, null, new ModelAndViewContainer(), nativeRequest);
    }

    /**
     * @return list of message converters
     */
    public List<HttpMessageConverter<?>> getMessageConverters() {
        return messageConverters;
    }
}

2 단계-AuthenticationEntryPoint 만들기

많은 자습서에서와 같이이 클래스는 사용자 지정 오류 처리를 구현하는 데 필수적입니다.

public class CustomEntryPoint implements AuthenticationEntryPoint {
    // The class from Step 1
    private MessageProcessor processor;

    public CustomEntryPoint() {
        // It is up to you to decide when to instantiate
        processor = new MessageProcessor();
    }

    @Override
    public void commence(HttpServletRequest request,
        HttpServletResponse response, AuthenticationException authException)
        throws IOException, ServletException {

        // This object is just like the model class, 
        // the processor will convert it to appropriate format in response body
        CustomExceptionObject returnValue = new CustomExceptionObject();
        try {
            processor.handle(returnValue, request, response);
        } catch (Exception e) {
            throw new ServletException();
        }
    }
}

3 단계-진입 점 등록

언급했듯이 Java Config로 수행합니다. 여기에 관련 구성 만 표시하고 세션 상태 비 저장 등과 같은 다른 구성이 있어야합니다 .

@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.exceptionHandling().authenticationEntryPoint(new CustomEntryPoint());
    }
}

일부 인증 실패 사례를 시도해보세요. 요청 헤더에 Accept : XXX 가 포함 되어야하며 JSON, XML 또는 기타 형식으로 예외가 발생해야합니다.


1
나는 잡으려고 노력하고 InvalidGrantException있지만 내 버전 CustomEntryPoint이 호출되지 않습니다. 내가 뭘 놓칠 수 있는지 아십니까?
Stefan Falk 2018 년

@이름 표시하기. 에 의해 체포 될 수없는 모든 인증 예외 AuthenticationEntryPointAccessDeniedHandler같은 UsernameNotFoundException과를 InvalidGrantException처리 할 수 있습니다 AuthenticationFailureHandler여기에 설명 .
Wilson

26

내가 찾은 가장 좋은 방법은 예외를 HandlerExceptionResolver에 위임하는 것입니다.

@Component("restAuthenticationEntryPoint")
public class RestAuthenticationEntryPoint implements AuthenticationEntryPoint {

    @Autowired
    private HandlerExceptionResolver resolver;

    @Override
    public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) throws IOException, ServletException {
        resolver.resolveException(request, response, null, exception);
    }
}

그런 다음 @ExceptionHandler를 사용하여 원하는 방식으로 응답 형식을 지정할 수 있습니다.


10
매력처럼 작동합니다. Spring이 autowirering에 대한 2 개의 bean 정의가 있다는 오류를 던지면 한정자 주석을 추가해야합니다. @Autowired @Qualifier ( "handlerExceptionResolver") private HandlerExceptionResolver resolver;
Daividh

2
널 핸들러를 전달 @ControllerAdvice하면 주석에 basePackages를 지정한 경우 작동하지 않습니다. 핸들러가 호출되도록하려면 이것을 완전히 제거해야했습니다.
Jarmex

왜 주셨어요 @Component("restAuthenticationEntryPoint")? restAuthenticationEntryPoint와 같은 이름이 필요한 이유는 무엇입니까? Spring 이름 충돌을 피하기위한 것입니까?
theprogrammer

@Jarmex 그래서 null 대신에 무엇을 통과 했습니까? 일종의 핸들러 맞죠? @ControllerAdvice로 주석 처리 된 클래스를 전달해야합니까? 감사합니다
theprogrammer

@theprogrammer, 나는 그것을 우회하기 위해 basePackages 주석 매개 변수를 제거하기 위해 응용 프로그램을 약간 재구성해야했습니다. 이상적이지 않습니다!
Jarmex

5

Spring Boot 및의 경우 Java 구성 대신 @EnableResourceServer확장 하고 메서드 내부에서 재정의 및 사용하여 사용자 지정 을 등록하는 것이 비교적 쉽고 편리합니다 .ResourceServerConfigurerAdapterWebSecurityConfigurerAdapterAuthenticationEntryPointconfigure(ResourceServerSecurityConfigurer resources)resources.authenticationEntryPoint(customAuthEntryPoint())

이 같은:

@Configuration
@EnableResourceServer
public class CommonSecurityConfig extends ResourceServerConfigurerAdapter {

    @Override
    public void configure(ResourceServerSecurityConfigurer resources) throws Exception {
        resources.authenticationEntryPoint(customAuthEntryPoint());
    }

    @Bean
    public AuthenticationEntryPoint customAuthEntryPoint(){
        return new AuthFailureHandler();
    }
}

또한 OAuth2AuthenticationEntryPoint확장 할 수 있고 (최종이 아니기 때문에) custom을 구현하는 동안 부분적으로 재사용 할 수 있는 멋진 기능 도 있습니다 AuthenticationEntryPoint. 특히 오류 관련 세부 정보와 함께 "WWW-Authenticate"헤더를 추가합니다.

이것이 누군가를 도울 수 있기를 바랍니다.


나는 이것을 시도하고 있지만 commence()내 기능 AuthenticationEntryPoint이 호출되지 않습니다-내가 뭔가를 놓치고 있습니까?
Stefan Falk

4

@Nicola 및 @Victor Wing의 답변을 받아보다 표준화 된 방법을 추가합니다.

import org.springframework.beans.factory.InitializingBean;
import org.springframework.http.HttpStatus;
import org.springframework.http.converter.HttpMessageConverter;
import org.springframework.http.server.ServerHttpResponse;
import org.springframework.http.server.ServletServerHttpResponse;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.AuthenticationEntryPoint;

import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;

public class UnauthorizedErrorAuthenticationEntryPoint implements AuthenticationEntryPoint, InitializingBean {

    private HttpMessageConverter messageConverter;

    @SuppressWarnings("unchecked")
    @Override
    public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) throws IOException, ServletException {

        MyGenericError error = new MyGenericError();
        error.setDescription(exception.getMessage());

        ServerHttpResponse outputMessage = new ServletServerHttpResponse(response);
        outputMessage.setStatusCode(HttpStatus.UNAUTHORIZED);

        messageConverter.write(error, null, outputMessage);
    }

    public void setMessageConverter(HttpMessageConverter messageConverter) {
        this.messageConverter = messageConverter;
    }

    @Override
    public void afterPropertiesSet() throws Exception {

        if (messageConverter == null) {
            throw new IllegalArgumentException("Property 'messageConverter' is required");
        }
    }

}

이제 구성된 Jackson, Jaxb 또는 MVC 주석 또는 XML 기반 구성의 응답 본문을 serializer, deserializer 등으로 변환하는 데 사용하는 모든 것을 삽입 할 수 있습니다.


저는 봄 부팅을 처음 접했습니다. "messageConverter 개체를 authenticationEntry 지점에 전달하는 방법"을 알려주세요.
Kona Suresh

세터를 통해. XML을 사용할 때 <property name="messageConverter" ref="myConverterBeanName"/>태그 를 만들어야합니다 . 당신이 사용하는 경우 @Configuration클래스를 바로 사용하는 setMessageConverter()방법을.
Gabriel Villacis

4

HandlerExceptionResolver이 경우 에 사용해야 합니다.

@Component
public class RESTAuthenticationEntryPoint implements AuthenticationEntryPoint {

    @Autowired
    //@Qualifier("handlerExceptionResolver")
    private HandlerExceptionResolver resolver;

    @Override
    public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException {
        resolver.resolveException(request, response, null, authException);
    }
}

또한 개체를 반환하려면 예외 처리기 클래스를 추가해야합니다.

@RestControllerAdvice
public class GlobalExceptionHandler extends ResponseEntityExceptionHandler {

    @ExceptionHandler(AuthenticationException.class)
    public GenericResponseBean handleAuthenticationException(AuthenticationException ex, HttpServletResponse response){
        GenericResponseBean genericResponseBean = GenericResponseBean.build(MessageKeys.UNAUTHORIZED);
        genericResponseBean.setError(true);
        response.setStatus(HttpStatus.UNAUTHORIZED.value());
        return genericResponseBean;
    }
}

의 여러 구현으로 인해 프로젝트를 실행할 때 오류가 발생할 수 있습니다 HandlerExceptionResolver.이 경우 추가해야합니다 @Qualifier("handlerExceptionResolver").HandlerExceptionResolver


GenericResponseBean단지 자바 POJO는 자신의 만들 수있는 수이다
VINIT Solanki

2

필터에서 'unsuccessfulAuthentication'메서드를 간단히 재정 의하여이를 처리 할 수있었습니다. 거기에서 원하는 HTTP 상태 코드와 함께 오류 응답을 클라이언트에 보냅니다.

@Override
protected void unsuccessfulAuthentication(HttpServletRequest request, HttpServletResponse response,
        AuthenticationException failed) throws IOException, ServletException {

    if (failed.getCause() instanceof RecordNotFoundException) {
        response.sendError((HttpServletResponse.SC_NOT_FOUND), failed.getMessage());
    }
}

1

업데이트 : 코드를 직접보고 싶은 경우 두 가지 예가 있습니다. 하나는 사용자가 찾고있는 표준 Spring Security를 ​​사용하고 다른 하나는 Reactive Web 및 Reactive Security에 해당하는 것을 사용하는 것입니다.
- Normal Web + Jwt Security
- Reactive Jwt

JSON 기반 엔드 포인트에 항상 사용하는 것은 다음과 같습니다.

@Component
public class JwtAuthEntryPoint implements AuthenticationEntryPoint {

    @Autowired
    ObjectMapper mapper;

    private static final Logger logger = LoggerFactory.getLogger(JwtAuthEntryPoint.class);

    @Override
    public void commence(HttpServletRequest request,
                         HttpServletResponse response,
                         AuthenticationException e)
            throws IOException, ServletException {
        // Called when the user tries to access an endpoint which requires to be authenticated
        // we just return unauthorizaed
        logger.error("Unauthorized error. Message - {}", e.getMessage());

        ServletServerHttpResponse res = new ServletServerHttpResponse(response);
        res.setStatusCode(HttpStatus.UNAUTHORIZED);
        res.getServletResponse().setHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE);
        res.getBody().write(mapper.writeValueAsString(new ErrorResponse("You must authenticated")).getBytes());
    }
}

객체 매퍼는 스프링 웹 스타터를 추가하면 빈이되지만 사용자 정의하는 것을 선호하므로 ObjectMapper에 대한 구현은 다음과 같습니다.

  @Bean
    public Jackson2ObjectMapperBuilder objectMapperBuilder() {
        Jackson2ObjectMapperBuilder builder = new Jackson2ObjectMapperBuilder();
        builder.modules(new JavaTimeModule());

        // for example: Use created_at instead of createdAt
        builder.propertyNamingStrategy(PropertyNamingStrategy.SNAKE_CASE);

        // skip null fields
        builder.serializationInclusion(JsonInclude.Include.NON_NULL);
        builder.featuresToDisable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
        return builder;
    }

WebSecurityConfigurerAdapter 클래스에서 설정 한 기본 AuthenticationEntryPoint :

@Configuration
@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class SecurityConfig extends WebSecurityConfigurerAdapter {
// ............
   @Autowired
    private JwtAuthEntryPoint unauthorizedHandler;
@Override
    protected void configure(HttpSecurity http) throws Exception {
        http.cors().and().csrf().disable()
                .authorizeRequests()
                // .antMatchers("/api/auth**", "/api/login**", "**").permitAll()
                .anyRequest().permitAll()
                .and()
                .exceptionHandling().authenticationEntryPoint(unauthorizedHandler)
                .and()
                .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS);


        http.headers().frameOptions().disable(); // otherwise H2 console is not available
        // There are many ways to ways of placing our Filter in a position in the chain
        // You can troubleshoot any error enabling debug(see below), it will print the chain of Filters
        http.addFilterBefore(authenticationJwtTokenFilter(), UsernamePasswordAuthenticationFilter.class);
    }
// ..........
}

1

필터를 사용자 정의하고 어떤 종류의 이상을 결정하십시오. 이보다 더 나은 방법이 있어야합니다.

public class ExceptionFilter extends OncePerRequestFilter {

@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws IOException, ServletException {
    String msg = "";
    try {
        filterChain.doFilter(request, response);
    } catch (Exception e) {
        if (e instanceof JwtException) {
            msg = e.getMessage();
        }
        response.setCharacterEncoding("UTF-8");
        response.setContentType(MediaType.APPLICATION_JSON.getType());
        response.getWriter().write(JSON.toJSONString(Resp.error(msg)));
        return;
    }
}

}


0

objectMapper를 사용하고 있습니다. 모든 Rest Service는 대부분 json과 함께 작동하며 구성 중 하나에서 이미 개체 매퍼를 구성했습니다.

코드는 Kotlin으로 작성되었으므로 괜찮을 것입니다.

@Bean
fun objectMapper(): ObjectMapper {
    val objectMapper = ObjectMapper()
    objectMapper.registerModule(JodaModule())
    objectMapper.configure(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS, false)

    return objectMapper
}

class UnauthorizedAuthenticationEntryPoint : BasicAuthenticationEntryPoint() {

    @Autowired
    lateinit var objectMapper: ObjectMapper

    @Throws(IOException::class, ServletException::class)
    override fun commence(request: HttpServletRequest, response: HttpServletResponse, authException: AuthenticationException) {
        response.addHeader("Content-Type", "application/json")
        response.status = HttpServletResponse.SC_UNAUTHORIZED

        val responseError = ResponseError(
            message = "${authException.message}",
        )

        objectMapper.writeValue(response.writer, responseError)
     }}

0

에서 ResourceServerConfigurerAdapter클래스, 아래의 코드는 나를 위해 일한 냈다. http.exceptionHandling().authenticationEntryPoint(new AuthFailureHandler()).and.csrf()..작동하지 않았다. 그래서 별도의 호출로 작성했습니다.

public class ResourceServerConfiguration extends ResourceServerConfigurerAdapter {

    @Override
    public void configure(HttpSecurity http) throws Exception {

        http.exceptionHandling().authenticationEntryPoint(new AuthFailureHandler());

        http.csrf().disable()
                .anonymous().disable()
                .authorizeRequests()
                .antMatchers(HttpMethod.OPTIONS).permitAll()
                .antMatchers("/subscribers/**").authenticated()
                .antMatchers("/requests/**").authenticated();
    }

토큰 만료 및 누락 된 권한 헤더를 포착하기위한 AuthenticationEntryPoint 구현.


public class AuthFailureHandler implements AuthenticationEntryPoint {

  @Override
  public void commence(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, AuthenticationException e)
      throws IOException, ServletException {
    httpServletResponse.setContentType("application/json");
    httpServletResponse.setStatus(HttpServletResponse.SC_UNAUTHORIZED);

    if( e instanceof InsufficientAuthenticationException) {

      if( e.getCause() instanceof InvalidTokenException ){
        httpServletResponse.getOutputStream().println(
            "{ "
                + "\"message\": \"Token has expired\","
                + "\"type\": \"Unauthorized\","
                + "\"status\": 401"
                + "}");
      }
    }
    if( e instanceof AuthenticationCredentialsNotFoundException) {

      httpServletResponse.getOutputStream().println(
          "{ "
              + "\"message\": \"Missing Authorization Header\","
              + "\"type\": \"Unauthorized\","
              + "\"status\": 401"
              + "}");
    }

  }
}


작동하지 않음 .. 여전히 기본 메시지 표시
aswzen
당사 사이트를 사용함과 동시에 당사의 쿠키 정책개인정보 보호정책을 읽고 이해하였음을 인정하는 것으로 간주합니다.
Licensed under cc by-sa 3.0 with attribution required.