잊지 않겠습니다.

WebSocket

Spring 4.0에서부터 지원하는 기능중 가장 눈여겨봐야지 될 내용은 WebSocket입니다.

WebSocket은 RFC6455에서 정의한 기능으로 다음과 같은 특징을 가지고 있습니다.

  • Real-time full duplex communication over TCP
  • Uses port 80 / 443
  • URL scheme : ws, wss (SSL)
  • Small overhead for text message (frame base) - 0x00 ~ 0xFF
  • Ping/Pong frames for staying alive

WebSocket을 이용해서 접근하게 되면 다음과 같은 Request와 Server Response가 나타납니다.

  • Request

    GET /echo HTTP/1.1
    Host: localhost:8080
    Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8
    Sec-WebSocket-Version: 13
    Origin: http://localhost:9000
    Sec-WebSocket-Key: HfYHmSeeKJWzbQv/K5yBVQ==
    Cookie: _ga=GA1.1.18456469.1406047636
    Connection: keep-alive, Upgrade
    Upgrade: websocket
    
  • Response

    HTTP/1.1 101 Switching Protocols
    Server: Apache-Coyote/1.1
    Upgrade: websocket
    Connection: upgrade
    Sec-WebSocket-Accept: i2iJBvVoYvXsVlrnYki5BNeKjew=
    Date: Wed, 23 Jul 2014 17:56:03 GMT
    

WebSocket의 javascript client code는 다음과 같이 구성될 수 있습니다.

  $scope.ws = new WebSocket('ws://localhost:8080/echo');
  $scope.ws.onopen = function() {
    console.log('websocket opened');
  };
  $scope.ws.onmessage = function(message) {
    console.log(message);
    console.log('receive message : ' + message.data);
  };
  $scope.ws.onclose = function(event) {
    console.log(event);
    console.log('websocket closed');
  };

이제 Spring 4.0에서 지원하는 WebSocket을 구성해보도록 하겠습니다. WebSocket을 지원하는 서버를 개발하기 위해서는 다음과 같은 절차로서 구성되어야지 됩니다.

1) MessageHandler의 구성
2) Spring Configuratio에 ConnectionEndPoint 설정

이제 한 단계식 구성을 따라가보도록 하겠습니다.

MessageHandler의 구성

Client에서 전송되는 message를 처리하는 MessageHandler입니다. MessageHandler는 크게 2가지로 나눌 수 있습니다. TextWebSocketHandler와 BinaryWebSocketHandler가 그것입니다. 이 두가지는 이름으로 알 수 있듯이, Text Message/Binary Message를 각각 처리하는데 사용됩니다.

먼저, Input Text Message에 ECHO:를 붙여주는 매우 단순한 TextMessageHandler를 구성해보도록 하겠습니다. TextMessgeHandler의 경우 매우 중요합니다. json data의 경우에도 역시 TextMessageHandler를 통해서 처리가 되는 것이 일반적이기 때문에 큰 Binary를 처리하거나, 아니면 암호화가 된 특정 이진데이터를 처리하는 것이 아니라면 대부분의 WebSocket 서버에서 사용될 내용은 TextMessageHandler를 기반으로 구성이 가능합니다.

단순한 EchoTextMessageHandler는 다음과 같이 구성될 수 있습니다.

@Component
public class EchoHandler extends TextWebSocketHandler {
    @Override
    protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception {
        TextMessage echoMessage = new TextMessage("ECHO :" + message.getPayload());
        session.sendMessage(echoMessage);
    }
}

ConnectionEndPoint의 구성

WebSocket을 이용한 client code를 보시면 쉽게 아실 수 있는 것이, connection의 endpoint가 존재합니다. 이 endpoint를 통해서 데이터를 보내고, 받는 작업을 할 수 있습니다. 이런 endpoint를 지정하는 작업은 다음과 같이 진행할 수 있습니다.

ConnectionEndPoint를 구성하기 위해서 Spring은 @EnableWebSocket annotation과 WebSocketConfigurer interface를 제공하고 있습니다. 여기서 중요한 것은 WebSocketConfigurer interface입니다. interface는 다음과 같이 구성됩니다.

public interface WebSocketConfigurer {
    void registerWebSocketHandlers(WebSocketHandlerRegistry registry);
}

registerWebSocketHandlers 라는 method 하나만이 존재하고, 이 method안에 connection endpoint를 다음과 같이 구성할 수 있습니다.

    public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {
        registry.addHandler(echoHandler, "/echo");
    }

/echo를 connection endpoint로 만들어주고, 연결된 connection에 대한 Handler를 지정해줍니다. DI를 위해 방금 만든 EchoHandler를 Bean으로 등록하고, 등록된 Bean을 이용하는 전체 @Configuration code는 다음과 같습니다.

@Configuration
@EnableWebSocket
@EnableWebMvc
@ComponentScan(basePackages = {
        "me.xyzlast.controllers",
        "me.xyzlast.handlers"
})
public class ControllerConfiguration extends WebMvcConfigurerAdapter implements WebSocketConfigurer {
    @Autowired
    private EchoHandler echoHandler;

    @Override
    public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {
        registry.addHandler(echoHandler, "/echo");
    }
}

위 설정은 WebSocket 서버와 WebMVC를 모두 다 지원하기 위해서 구성한 결과입니다.

이제 WebSocket을 이용한 Echo Server 구성이 모두 마쳐졌습니다. tomcat 7.x 이상이 필요하며, angularJS를 이용한 client page는 다음과 같이 구성할 수 있습니다.

angular.module('websocketClientApp').controller('WsCtrl', function ($scope, $timeout) {
//STARTOFCONTROLLER
$scope.message = '';
$scope.ws;
$scope.echoMessages = [];

$scope.init = function() {
  $scope.ws = new WebSocket('ws://localhost:8080/echo');
  $scope.ws.onopen = function() {
    console.log('websocket opened');
  };
  $scope.ws.onmessage = function(message) {
    console.log(message);
    console.log('receive message : ' + message.data);
    $scope.echoMessages.unshift(message.data);
    $timeout(function() {
      $scope.$apply('echoMessages');
    })
  };
  $scope.ws.onclose = function(event) {
    console.log(event);
    console.log('websocket closed');
  };
};

$scope.send = function() {
  $scope.ws.send($scope.message);
};

$scope.init();
//ENDOFCONTROLLER
});
<h3>WebSocket Test Page</h3>

<input type="text" ng-model="message"/>
<button ng-click="send()">SEND</button>

<ul ng-repeat="echo in echoMessages">
  <li>{{echo}}</li>
</ul>

결과는 다음과 같습니다.

SocketJS

WebSocket은 지금까지 사용되고 있던 Ajax에 비하여 매우 훌륭한 방법입니다. 그렇지만, WebSocket이 Spring 4.0에서나 지원이 된 이유는 약간 발전이 너무나도 느렸습니다. WebSocket을 지원하는 Browser의 종류는 다음과 같습니다.

WebSocket의 경우, IE 10이상에서 지원하고 있기 때문에, 현실상 지원하기 힘듭니다. 이 문제를 해결하기 위해서, socket.io와 같은 websocket을 지원하지 않는 browser에서 websocket과 같이 접근하기 위한 방법들이 계속해서 나왔는데, SockJS도 그 방법들중 하나입니다.

SockJS는 기본적으로 WebSocket을 이용하고 있고, interface가 동일합니다. 다만 WebSockeet과는 다음과 같은 차이점을 가지고 있습니다.

  1. schema가 ws가 아닌 http
  2. WebSocket과 같이 Browser에서 제공되는 library가 아닌, 외부 library 사용
  3. IE 6이상부터 지원합니다.
  4. Server 측에서도 websocket이 아닌 SockJS server side library를 사용

websocket과 sockjs의 client code는 다음과 같습니다. 비교해보시면 schema만을 제외하면 완전히 동일한 것을 알 수 있습니다.

WebSocket

var ws = new WebSocket('ws://domain/endpoint');
ws.onopen = function() {
  console.log('open socket');
}
ws.onmessage = function(message) {
  console.log('message', message.data);
}
ws.onclose = function(e) {
  console.log('close');
}

SockJS

var sock = new SockJS('http://domain/endpoint');
sock.onopen = function() {
  console.log('open socket');
}
sock.onmessage = function(message) {
  console.log('message', message.data);
}
sock.onclose = function(e) {
  console.log('close');
}

Spring 4.0은 SockJS 서버를 지원하고 있으며, 지원 방법은 WebSocket 서버와 거의 유사합니다. 아니 동일한 수준입니다.
SockJS 서버의 구축 방법은 WebSocket 서버의 구축 방법과 동일합니다.

1) MessageHandler의 구성
2) Spring Configuratio에 ConnectionEndPoint 설정

MessageHandler의 구성

MessageHandler는 WebSocket과 완전히 동일합니다.

Connection EndPoint의 구성

Connection EndPoint의 경우에도 거의 유사합니다. 다음은 WebSocket의 endpoint와 SockJS의 endpoint 비교입니다.

    @Override
    public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {
        registry.addHandler(echoHandler, "/echo");
        registry.addHandler(echoHandler, "/echojs").withSockJS();
    }

주의할점은 SockJS와 WebSocket의 endpoint를 동일하게 넣어서는 안된다는 점입니다. 같은 port를 이용하고, schema만이 다르기 때문에 접근되는 endpoint가 동일한 경우, 오동작을 발생시키면서 connection이 종료됩니다.

SockJS client의 구성

SockJS의 경우, bower에서 지원하고 있습니다. 다음 command를 통해서 bower로 install을 할 수 있습니다.

bower install sockjs --save

angularjs + yeoman을 통해서 구성한 sockjs client code는 다음과 같습니다.

angular.module('websocketClientApp').controller('SockCtrl', function ($scope) {
//STARTOFCONTROLLER
$scope.message = '';
$scope.sock;

$scope.init = function() {
  $scope.sock = new SockJS('http://localhost:8080/echojs');
  $scope.sock.onopen = function() {
    console.log('websocket opened');
  };
  $scope.sock.onmessage = function(message) {
    console.log(message);
    console.log('receive message : ' + message.data);
  };
  $scope.sock.onclose = function(event) {
    console.log(event);
    console.log('websocket closed');
  };
};

$scope.send = function() {
  $scope.sock.send($scope.message);
};

$scope.init();
//ENDOFCONTROLLER
});
<script src="bower_components/sockjs/sockjs.js"></script>

<h3>SockJS Test Page</h3>

<input type="text" ng-model="message"/>
<button ng-click="send()">SEND</button>

STOMP

STOMP는 text 지향의 message protocol입니다. websocket을 이용한 message handling을 보다 더 쉽게 만들어줍니다. WebSocket이나 SockJS의 경우 onmessage function에서 받는 메세지를 모두 handling해야지 되는 단점을 가지고 있습니다. 이러한 단점을 구독(subscription)과 사용자 구독(user)를 통해서 처리할 수 있다는 장점을 가지고 있습니다.

STOMP를 이해하기 위해서는 Client code의 흐름을 보는 것이 좀 더 이해가 편합니다.

$scope.client;
$scope.name = '';
$scope.message = '';

$scope.init = function() {
  var socket = new SockJS('http://localhost:8080/endpoint'); //SockJS endpoint를 이용
  $scope.client = Stomp.over(socket); //Stomp client 구성
  $scope.client.connect({}, function(frame) {
    console.log('connected stomp over sockjs');
    // subscribe message
    $scope.client.subscribe('/subscribe/echo', function(message) {
      console.log('receive subscribe');
      console.log(message);
    });
  });
};

// send message
$scope.send = function() {
  var data = {
    name: $scope.name,
    message: $scope.message
  };
  $scope.client.send('/app/echo', {}, JSON.stringify(data));
};

STOMP는 기본적으로 subscribe를 통해서 데이터를 전달받습니다. 따라서, 이 message가 어떠한 method에 의해서 전송을 받았는지를 좀 더 명확히 할 수 있습니다. 기존의 WebSocket이나 SockJS를 이용하게 되는 경우에는 전달받는 message의 범위가 매우 광범위하기 때문에, 전달되는 메세지에 message key값을 넣어서 send/receive에 대한 처리를 따로 해줘야지 되는 것이 일반적입니다. 그렇지만, STOMP의 경우에는 접근하는 방식이 send point와 subscribe point가 각기 다르기 때문에 처리를 좀 더 자유롭게 해줄 수 있습니다.

STOMP에서는 subscribe와 user/** 두개로 subscribe의 target을 나눌수 있는데, 각각의 차이는 전자는 전역이고, 후자는 호출된 사용자에게만 던져진다는 차이를 가지고 있습니다.

이제 Spring을 이용한 STOMP 서버 구축에 들어가보도록 하겠습니다.

Dependency 설정

STOMP를 지원하기 위해서 Spring은 spring-message에 대한 dependency를 필요로 합니다.

compile "org.springframework:spring-messaging:${rootProject.ext.springVersion}"

@Configuration 설정

STOMP를 지원하기 위해서는 @EnableWebSocketMessageBroker과 @Configuration에서는 AbstractWebSocketMessageBrokerConfigurer를 상속받거나, WebSocketMessageBrokerConfigurer 인터페이스를 구현해야지 됩니다.

@Configuration에서 할 일은 2가지입니다.

1) EndPoint의 설정
2) (option) STOMP URL에 대한 전역 prefix 설정
3) (option) send에 대한 subscribe url의 prefix 설정
4) @EnableWebSocketMessageBroker 설정

EndPoint 설정

EndPoint의 경우, SockJS를 사용할지, WebSocket을 사용할지에 따라 다른 설정을 해주게 됩니다. STOMP를 이용하는 것은 대부분 SockJS를 이용해서 Browser의 적합성을 높이는 경우가 많기 때문에, SockJS를 이용하는 것이 일반적입니다. WebSocketMessageBrokerConfigurer 인터페이스의 registerStompEndpoints method를 다음과 같이 정의합니다.

    @Override
    public void registerStompEndpoints(StompEndpointRegistry registry) {
        registry.addEndpoint("/endpoint").withSockJS();
    }
prefix 설정

Send Url에 대한 전역 prefix와 subscribe에 대한 전역 prefix를 설정할 수 있습니다. 이는 WebSocketMessageBrokerConfigurer 인터페이스의 configureMessageBroker method 에서 수정하면 됩니다. (아래는 모든 send prefix를 app으로 지정하고, subscribe의 기본 prefix를 subscribe로 지정했습니다.)

    @Override
    public void configureMessageBroker(MessageBrokerRegistry registry) {
        registry.enableSimpleBroker("/subscribe");
        registry.setApplicationDestinationPrefixes("/app");
    }
@EnableWebSocketMessageBroker 설정

모든 설정이 완료된 @Configuration code는 다음과 같습니다.

@Configuration
@EnableWebSocketMessageBroker
@ComponentScan(basePackages = {
        "me.xyzlast.controllers"
})
public class StompConfigurer extends AbstractWebSocketMessageBrokerConfigurer {

    @Override
    public void registerStompEndpoints(StompEndpointRegistry registry) {
        registry.addEndpoint("/endpoint").withSockJS();
    }

    @Override
    public void configureMessageBroker(MessageBrokerRegistry registry) {
        registry.enableSimpleBroker("/subscribe");
        registry.setApplicationDestinationPrefixes("/app");
    }
}

@Controller 구성

STOMP의 @Controller는 Spring MVC의 @Controller와 같은 @annotation을 사용하고, STOMP를 제공하기 위한 다음 @annotation들을 제공합니다.

@annotationdescription
@MessageMappingSTOMP client의 send에 대한 target url입니다.
@SendToSTOMP client의 subscribe에 대한 target url을 지정합니다. (지정되지 않은 경우, @MessageMapping에 지정된 URL + @Configuration에서 설정된 prefix URL을 이용합니다. )
@SubscribeEventclient에서 subscribe 할 수 있는 url을 지정합니다. 특정 message가 발생하거나 event가 발생했을 때, Client에 값을 전송하는데 사용합니다.

annotation이 적용된 @Controller code는 다음과 같이 구성될 수 있습니다.

@Controller
public class EchoController {
    @MessageMapping("/echo")
    @SendTo("/subscribe/echo")
    public Hello sendEcho(Hello hello) throws Exception {
        System.out.println("receive message : " + hello.toString());
        Hello echoHello = new Hello();
        echoHello.setMessage(hello.getName());
        echoHello.setName(hello.getMessage());
        return echoHello;
    }
}

위 @Controller의 sendEcho methos는 /echo url을 통해 데이터를 전달받고, /subscribe/echo를 통해 데이터를 전달하게 됩니다.

기존의 SockJS와는 다르게 MessageHandler를 따로 구현하지 않습니다. 기본적으로 @Controller를 이용해서 처리하기 때문에 기존의 WebMvc와 거의 유사한 형태의 개발을 진행할 수 있습니다.

User Destination

STOMP에서는 특정 사용자들에게 메세지를 보낼 수 있습니다. 기본값으로 설정되어 있는 /user로 시작되는 subscribe의 경우에는 각 사용자들이 받을 수 있는 url을 제공합니다. 이는 사용자들의 특화된 message를 보내거나 error handling시에 주로 사용됩니다.

client.subscribe('/user/queue/private-message') = function(messaage) {

}

이러한 User subscribe는 Spring에서 @SendToUser annotation으로 기능이 따로 제공되고 있습니다.

    @MessageMapping("/message")
    @SendToUser
    public String sendMessage(String message) {
        return message.toUpperCase();
    }

    @MessageExceptionHandler
    @SendToUser("/queue/errors")
    public String handlingException(IllegalStateException ex) {
        return ex.getMessage();
    }


Posted by Y2K
,

Spring Security를 이용한 REST API를 만들때, 일반적인 Spring Security를 이용하는 경우에는 다Login 성공 또는 실패시에 모두 302번 Redirect가 발생하게 됩니다. 또한 인증되지 않은 사용자가 API에 접근한 경우에도 마찬가지로 302번 Redirect로 Login Page로 이동하게 되는데, 이는 REST API 개발시에는 원하지 않는 결과입니다.

먼저, 개발할 API 서버는 Form 인증을 지원하는 것을 전제로 합니다. Basic/Digest 인증의 경우에는 다른 방법으로 개발이 되어야지 됩니다.

일반적인 Form 인증을 이용하는 경우에 Spring Security의 기본 Form인증은 다음 문제를 해결해야지 됩니다.

  1. 인증되지 않은 Request가 들어오는 경우, 인증 Page로 Redirect
  2. Login 성공시, 200 Response가 되지 않고, Success Page로 Redirect
  3. Login 실패시, 200 Response가 되지 않고, Failure Page로 Redirect

결국은 모두 Redirect문제가 발생하게 됩니다. 이에 조금 추가를 해서, 일반적으로는 Spring Security는 application이 시작할 때, 각각의 URL에 대한 ROLE을 설정하게 됩니다. 이러한 ROLE의 설정 없이, Request가 발생하였을때 인증된 사용자인 경우에 ROLE을 확인하는 기능을 추가해보도록 하겠습니다.

인증되지 않는 Request에 대한 문제해결

Spring Security는 ErrorHandling에 인증되지 않았을 때의 동작을 AuthenticationEntryPoint를 설정하여 제어할 수 있습니다. 기본적으로 Form인증을 사용하는 경우에는 org.springframework.security.web.authentication.LoginUrlAuthenticationEntryPoint가 설정이 됩니다. Spring code를 보면 다음과 같이 구성되어 있습니다.

public class LoginUrlAuthenticationEntryPoint implements AuthenticationEntryPoint, InitializingBean {
    public LoginUrlAuthenticationEntryPoint(String loginFormUrl) {
        this.loginFormUrl = loginFormUrl;
    }


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

        String redirectUrl = null;

        if (useForward) {
            if (forceHttps && "http".equals(request.getScheme())) {
                // First redirect the current request to HTTPS.
                // When that request is received, the forward to the login page will be used.
                redirectUrl = buildHttpsRedirectUrlForRequest(request);
            }

            if (redirectUrl == null) {
                String loginForm = determineUrlToUseForThisRequest(request, response, authException);

                if (logger.isDebugEnabled()) {
                    logger.debug("Server side forward to: " + loginForm);
                }

                RequestDispatcher dispatcher = request.getRequestDispatcher(loginForm);

                dispatcher.forward(request, response);

                return;
            }
        } else {
            // redirect to login page. Use https if forceHttps true

            redirectUrl = buildRedirectUrlToLoginPage(request, response, authException);

        }

        redirectStrategy.sendRedirect(request, response, redirectUrl);
    }
}

보시면 최종적으로는 sendRedirect method를 통해서 login url로 redirect를 시켜주고 있는 것을 볼 수 있습니다. 이 부분에 대한 제어만을 변경시켜준다면 우리가 원하는 조건을 만들어줄 수 있습니다.

새롭게 만들어지는 RestAuthenticationHandler는 매우 단순한 코드를 갖습니다. 인증이 되지 않은 사용자들에게 401 status를 던질뿐이니까요.

@Component
public class RestAuthenticationEntryPoint implements AuthenticationEntryPoint {
    @Override
    public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException {
        response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "Unauthorized");
    }
}

이제 이 부분을 Spring Security Configuration에 설정을 해줍니다.

@Configuration
@EnableWebSecurity
@ComponentScan(basePackages = "com.daesung.fms.api.securities")
public class SecurityConfiguration extends WebSecurityConfigurerAdapter {
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
            .csrf().disable()
            .exceptionHandling()
                .authenticationEntryPoint(restAuthenticationEntryPoint())
            .formLogin()
                .loginProcessingUrl("/auth/login")
                .usernameParameter("username")
                .passwordParameter("password")
                .and()
            .logout()
                .logoutUrl("/auth/logout");
    }
}

exceptionHandling()에 구성된 RestAuthenticationEntryPoint 객체만을 설정해주는 것으로 이제 인증되지 않은 request에는 401 status를 보내줄 수 있게 되었습니다.

인증 성공시, 200 status 반환

Spring Security를 이용한 Form 인증을 성공하는 경우, 일반적으로 SuccessfulUrl로 Redirect가 됩니다. 그런데 REST API의 경우에는 이와 같은 구성이 문제가 됩니다. 우리는 인증을 정상적으로 했는지에 대한 확인이 필요한 것이지, 인증을 한 후에 특정 Page로의 Redirect를 원하는 것이 아니기 때문입니다.

Form 인증에서, 인증이 성공한 경우에는 Spring Security 는 기본적으로 org.springframework.security.web.authentication.SavedRequestAwareAuthenticationSuccessHandler에서 동작을 담당합니다. code를 보면 다음과 같습니다.

public class SavedRequestAwareAuthenticationSuccessHandler extends SimpleUrlAuthenticationSuccessHandler {
    @Override
    public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response,
            Authentication authentication) throws ServletException, IOException {
        SavedRequest savedRequest = requestCache.getRequest(request, response);

        if (savedRequest == null) {
            super.onAuthenticationSuccess(request, response, authentication);

            return;
        }
        String targetUrlParameter = getTargetUrlParameter();
        if (isAlwaysUseDefaultTargetUrl() || (targetUrlParameter != null && StringUtils.hasText(request.getParameter(targetUrlParameter)))) {
            requestCache.removeRequest(request, response);
            super.onAuthenticationSuccess(request, response, authentication);

            return;
        }

        clearAuthenticationAttributes(request);

        // Use the DefaultSavedRequest URL
        String targetUrl = savedRequest.getRedirectUrl();
        logger.debug("Redirecting to DefaultSavedRequest Url: " + targetUrl);
        getRedirectStrategy().sendRedirect(request, response, targetUrl);
    }

    public void setRequestCache(RequestCache requestCache) {
        this.requestCache = requestCache;
    }
}

매우 단순한 code입니다. 여기서 중요한 것은 onAuthenticationSuccess method의 끝부분에 위치한 getRedirectStraegy()입니다. 이 부분만을 제거하고, 200 status만을 반환할 수 있다면 우리가 원하는 결과를 얻어낼 수 있습니다. 인증이 성공한 다음에 실행되기 때문에 매우 단순하게 만들 수 있습니다.

public class RestLoginSuccessHandler extends SimpleUrlAuthenticationSuccessHandler {

    private RequestCache requestCache = new HttpSessionRequestCache();

    @Override
    public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
        handle(request, response, authentication);
        clearAuthenticationAttributes(request);
    }

    protected void handle(HttpServletRequest request, HttpServletResponse response, Authentication authentication)
            throws IOException, ServletException {
        SavedRequest savedRequest = requestCache.getRequest(request, response);

        if (savedRequest == null) {
            clearAuthenticationAttributes(request);
            return;
        }
        String targetUrlParam = getTargetUrlParameter();
        if (isAlwaysUseDefaultTargetUrl() ||
                (targetUrlParam != null &&
                        StringUtils.hasText(request.getParameter(targetUrlParam)))) {
            requestCache.removeRequest(request, response);
            clearAuthenticationAttributes(request);
            return;
        }
        clearAuthenticationAttributes(request);
    }
}

기본적으로 매우 단순하게 clearAuthenticationAttribute만 해주고, 다른 처리는 해줄 필요가 없습니다. 아무런 Exception이 발생하지 않고, response에 특별히 status를 설정하지 않는 경우에는 200 status를 반환하게 되니까요.

이제 작성된 AuthenticationSuccessHandler를 설정하면 Spring Security는 다음과 같은 코드가 됩니다.

@Configuration
@EnableWebSecurity
@ComponentScan(basePackages = "com.daesung.fms.api.securities")
public class SecurityConfiguration extends WebSecurityConfigurerAdapter {
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
            .csrf().disable()
            .exceptionHandling()
                .authenticationEntryPoint(restAuthenticationEntryPoint())
            .formLogin()
                .loginProcessingUrl("/auth/login")
                .usernameParameter("username")
                .passwordParameter("password")
                .successHandler(loginSuccessHandler()) //추가된 부분
                .and()
            .logout()
                .logoutUrl("/auth/logout")
            .authorizeRequests().anyRequest().authenticated();
    }
}

인증 실패 및 로그 아웃시 동작 설정

인증 실패시의 동작과 로그 아웃시의 동작 역시 인증 성공시와 동일하게 처리할 수 있습니다. 조금 차이가 있다면 인증 실패시에는 401번 UNAUTHENTICATION status를 보내야지 되고, 로그 아웃시에는 항시 200 status를 보내야지 되는 차이만이 있을 뿐입니다.

인증실패시의 Handler는 다음과 같이 구성될 수 있습니다.

/**
 * Created by ykyoon on 14. 4. 22.
 * when login failed, return http status code (401).
 */
public class RestLoginFailureHandler implements AuthenticationFailureHandler {
    @Override
    public void onAuthenticationFailure(HttpServletRequest request,
                                        HttpServletResponse response,
                                        AuthenticationException exception) throws IOException, ServletException {
        response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "Unauthorized");
    }
}

그리고, 로그아웃시의 Handler 역시 다음과 같은 code로 구성가능합니다.

/**
 * Created by ykyoon on 14. 4. 21.
 * after Logouted, send http status ok code (200).
 */
public class RestLogoutSuccessHandler extends SimpleUrlLogoutSuccessHandler {
    @Override
    public void onLogoutSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
        String targetUrl = determineTargetUrl(request, response);
        if (response.isCommitted()) {
            logger.debug("Response has already been committed. Unable to redirect to " + targetUrl);
        }
    }
}

이제 구성된 두개의 Handler를 추가하면 Spring Security는 다음 code와 같이 변경됩니다.

@Configuration
@EnableWebSecurity
@ComponentScan(basePackages = "com.daesung.fms.api.securities")
public class SecurityConfiguration extends WebSecurityConfigurerAdapter {
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
            .csrf().disable()
            .exceptionHandling()
                .authenticationEntryPoint(restAuthenticationEntryPoint())
            .formLogin()
                .loginProcessingUrl("/auth/login")
                .usernameParameter("username")
                .passwordParameter("password")
                .successHandler(loginSuccessHandler()) 
                .failureHandler(loginFailureHandler()) //추가2
                .and()
            .logout()
                .logoutUrl("/auth/logout")
                .logoutSuccessHandler(logoutSuccessHandler()) //추가2
            .authorizeRequests().anyRequest().authenticated();
    }
}

이렇게 구성하면 이제 Spring Security로 구성되는 REST API를 구성할 수 있습니다.

API URL별 권한 설정

REST API를 개발하면, 각 URL에 따른 사용 권한을 넣어주는 것이 일반적입니다. 또는 URL 단위로 권한을 묶어서 관리하게 되는데, 그럼 API가 하나하나 추가 될때마다 security의 설정에 매번 URL을 hard coding으로 넣어주게 된다면 이는 매우 큰 비용으로 발생되게 됩니다. 사용자에게 권한만 넘겨주고, 그 권한과 URL을 Request마다 체크를 해서 권한을 확인하면 Security에 대한 권한 코드를 좀더 동적으로 사용할 수 있습니다. (DB 값만을 변경시켜주면 URL에 따른 권한을 변경할 수 있습니다.)

먼저, Spring Security는 각각의 URL과 ROLE간의 Matching을 어떻게 하고 있는지에 대한 구조에 대한 이해가 필요합니다. Spring Security의 URL-ROLE간의 matching은 다음과 같이 진행됩니다.

  1. FilterInvocationSecurityMetadataSource을 통해 URL/METHOD를 이용한 접근권한이 어떻게 되는지 확인합니다. 이 과정이 매우 중요합니다. 우리가 Spring Security에 각각의 URL별 권한을 hard coding으로 넣어주는 경우에는, 이 MetadataSource가 hard coding된 것과 같은 역활을 하게 됩니다.
  2. FilterInvocationSecurityMetadataSource에서 얻어온 ROLE들과 사용자가 갖은 ROLE을 이용해서 AccessVote를 진행합니다.

이러한 두과정을 거치는 Filter가 존재를 하는데, 이 필터가 아래 그림의 최종적인 Filter인 FilterSecurityInterceptor입니다.

이러한 Filter Chain에서 새로운 Filter를 만들어서 Spring Security에 추가를 할 예정입니다. Spring Security의 FilterSecurityInterceptor에는 모든 인증된 Request만이 접근할 수 있도록 설정을 하고, 그 뒤에 우리가 새롭게 만든 Filter를 통해서 인증을 처리할 계획입니다.

먼저, 새로운 FilterInvocationSecurityMetaSource가 필요합니다. 이는 FilterInvocationSecurityMetadataSource interface를 상속하면 매우 간단하게 구성 가능합니다.

/**
 * Created by ykyoon on 14. 4. 21.
 * DB 기반의 인증 관리 시스템.
 */
public class FmsFilterInvocationSecurityMetadataSource implements FilterInvocationSecurityMetadataSource {

    public FmsFilterInvocationSecurityMetadataSource(MenuRoleService menuRoleService) {
        this.menuRoleService = menuRoleService;
        parser = new FmsUrlParser();
        permissions = new Hashtable<>();
    }
    private final MenuRoleService menuRoleService;
    private final FmsUrlParser parser;
    private final Map<String, Permission> permissions;

    @Override
    public Collection<ConfigAttribute> getAttributes(Object object) throws IllegalArgumentException {
        final HttpServletRequest request = ((FilterInvocation) object).getRequest();
        final String httpMethod = request.getMethod().toUpperCase();
        final String url = parser.parse(request.getRequestURI());
        final String key = String.format("%s %s", httpMethod, url);

        final Permission permission;
        if(permissions.containsKey(key)) {
            permission = permissions.get(key);
        } else {
            permission = menuRoleService.findByMethodAndUrl(httpMethod, url);
            if(permission != null) {
                permissions.put(key, permission);
            }
        }

        String[] roles;
        if(permission == null) {
            roles = new String[] { "ROLE_ADMIN" };
        } else {
            roles = new String[] { "ROLE_ADMIN", permission.getName() };
        }
        return SecurityConfig.createList(roles);
    }

    @Override
    public Collection<ConfigAttribute> getAllConfigAttributes() {
        return null;
    }

    @Override
    public boolean supports(Class<?> clazz) {
        return FilterInvocation.class.isAssignableFrom(clazz);
    }

    /**
     * url - permission hashmap clear
     */
    public void clear() {
        permissions.clear();
    }
}

작성된 code의 menuRoleService는 request URL과 http method를 이용해서 접근 가능한 ROLE을 반환합니다. 약간의 tip으로, DB를 계속해서 읽는 것보다는 Hashtable을 이용해서 한번 읽어들인 ROLE은 또다시 DB 조회를 하지 않도록 MAP을 가지고 있도록 구성하였습니다.

이제 AccessVote를 구현할 차례입니다. AccessVote는 FilterInvocationSecurityMetadataSource에서 넘겨준 ROLE과 사용자의 ROLE간의 비교를 해주게 됩니다.

/**
 * Created by ykyoon on 14. 4. 22.
 * Authorized Name 기반의 인증 Vote class
 */
@Slf4j
public class FmsAccessDecisionVoter implements AccessDecisionVoter<FilterInvocation> {

    @Override
    public boolean supports(ConfigAttribute attribute) {
        return attribute instanceof SecurityConfig;
    }

    @Override
    public boolean supports(Class<?> clazz) {
        return clazz != null && clazz.isAssignableFrom(FilterInvocation.class);
    }

    @Override
    public int vote(Authentication authentication, FilterInvocation fi, Collection<ConfigAttribute> attributes) {
        assert authentication != null;
        assert fi != null;
        assert attributes != null;

        SecurityConfig securityConfig = null;

        boolean containAuthority = false;
        for(final ConfigAttribute configAttribute : attributes) {
            if(configAttribute instanceof SecurityConfig) {
                securityConfig = (SecurityConfig) configAttribute;
                for(GrantedAuthority grantedAuthority : authentication.getAuthorities()) {
                    containAuthority = securityConfig.getAttribute().equals(grantedAuthority.getAuthority());
                    if(containAuthority) {
                        break;
                    }
                }
                if(containAuthority) {
                    break;
                }
            }
        }
        return containAuthority ? ACCESS_GRANTED : ACCESS_DENIED;
    }
}

AccessDecisionVoter는 하는 일이 매우 단순합니다. Authentication이 넘어왔고, 이에 대한 규칙을 이용해서 ROLE을 matching이 되는 것이 있는지 없는지를 확인하는 과정을 거치게 됩니다. 이 코드를 조금 더 수정을 한다면 권한 1, 권한 2, 권한 3 모두를 갖는 사용자만이 접근할 수 있는 규칙 역시 만드는 것이 가능합니다. 이건 Security에 대한 운영원칙과 BL에 따라 달라질 것입니다.

이제 구성된 FilterInvocationSecurityMetadataSource와 AccessDecisionVoter를 Spring Security에 추가해보도록 하겠습니다.

먼저 Bean들을 등록시켜줍니다.

    /**
     * FMS API 권한 Filter.
     * @return securityMetadataSource() 가 적용된 데이터.
     * @throws Exception
     */
    @Bean
    public FilterSecurityInterceptor filterSecurityInterceptor() throws Exception {
        FilterSecurityInterceptor filterSecurityInterceptor = new FilterSecurityInterceptor();
        filterSecurityInterceptor.setSecurityMetadataSource(securityMetadataSource());
        filterSecurityInterceptor.setAuthenticationManager(authenticationManager());
        filterSecurityInterceptor.setAccessDecisionManager(affirmativeBased());
        return filterSecurityInterceptor;
    }

    //여러 Voter를 추가하는 것이 가능. List로 index가 작은 Voter가 먼저 투표. 투표 포기(ACCESS_ABSTAIN)
    //가 되는 경우, 다음 Voter에게 넘어감.
    @Bean
    public AffirmativeBased affirmativeBased() {
        FmsAccessDecisionVoter voter = new FmsAccessDecisionVoter();
        List<AccessDecisionVoter> voters = new ArrayList<>();
        voters.add(voter);
        return new AffirmativeBased(voters);
    }

    @Bean
    public FilterInvocationSecurityMetadataSource securityMetadataSource() {
        return new FmsFilterInvocationSecurityMetadataSource(menuRoleService);
    }

그리고, FilterSecurityInterceptor 뒤에 우리가 만들어준 Filter를 등록시켜주면 됩니다.

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
            .csrf().disable()
            .exceptionHandling()
                .authenticationEntryPoint(restAuthenticationEntryPoint())
                .and()
            .formLogin()
                .loginProcessingUrl("/auth/login")
                .usernameParameter("username")
                .passwordParameter("password")
                .successHandler(loginSuccessHandler())
                .failureHandler(loginFailureHandler())
                .and()
            .logout()
                .logoutUrl("/auth/logout")
                .logoutSuccessHandler(logoutSuccessHandler())
                .and()
            .authorizeRequests().anyRequest().authenticated()
                .and()
            .addFilterAfter(filterSecurityInterceptor(), FilterSecurityInterceptor.class);
    }

Summary

REST API 서비스 구현시에, Spring Security를 이용한 인증처리를 만들었습니다. 기본적인 Form 인증과 구성된 Security 다음 동작이 다릅니다.

동작기본 Spring Security FormREST Security Form
인증성공시설정된 인증 성공 URL로 302 Redirect200 status return
인증실패시설정된 인증 실패 URL로 302 Redirect401 unauthentication status return
로그아웃시설정된 로그아웃 성공 URL로 302 Redirect200 status return

또한, API의 추가 또는 권한변경시 코드의 변경 없이, DB값을 조회해와서 동적인 ROLE을 설정하는 것이 가능합니다.

인증은 매우 중요하고, 인증은 회사의 운영원칙에 많은 변경이 오게 되는 것이 사실입니다. 특히 REST API를 구성한다면 이 인증에 대한 많은 고민들이 필요하실 것이라고 생각하며 이만 글을 줄입니다. 모두들 Happy Coding~

Posted by Y2K
,

기본적으로 Spring MVC를 이용하는 경우, multipart encrypt를 이용해서 form submit으로 file을 upload하는 것이 일반적입니다. 한번 javascript만을 이용한 file upload를 해보도록 하겠습니다.

먼저, MultipartResolver를 사용하기 위해서 apache common fileupload를 추가해야지 됩니다.

compile 'commons-fileupload:commons-fileupload:1.3.1'

Spring Configuration에 MultipartResolver를 추가합니다.

    @Bean(name = "multipartResolver")
    public MultipartResolver multipartResolver() {
        return new CommonsMultipartResolver();
    }

그리고, 간단한 fileupload html을 꾸밉니다.

<form id="uploadForm" enctype="multipart/form-data">
  <input type="file" id="fileId" name="file-data"/>
</form>
<button id="btn-upload">file upload</button>

그리고, 이에 대한 controller code는 다음과 같습니다.

    @RequestMapping(value = "/file/upload", method = RequestMethod.POST)
    @ResponseBody
    public Object uploadFile(MultipartHttpServletRequest request) {
        Iterator<String> itr =  request.getFileNames();
        if(itr.hasNext()) {
            MultipartFile mpf = request.getFile(itr.next());
            System.out.println(mpf.getOriginalFilename() +" uploaded!");
            try {
                //just temporary save file info into ufile
                System.out.println("file length : " + mpf.getBytes().length);
                System.out.println("file name : " + mpf.getOriginalFilename());
            } catch (IOException e) {
                System.out.println(e.getMessage());
                e.printStackTrace();
            }
            return true;
        } else {
            return false;
        }
    }

일단, 간단히 파일을 실제적으로 저장은 시키지 않았습니다. byte data를 얻어냈기 때문에, byte data만 저장하면 되어서 일단 위의 코드에서는 제거 되어있습니다.

이제 ajax로 file을 올리기 위해서 button에 click event를 binding시켜줍니다.

  $('#btn-upload').on('click', function () {
    console.log('btn-upload');
    var form = new FormData(document.getElementById('uploadForm'));
    $.ajax({
      url: "upload",
      data: form,
      dataType: 'text',
      processData: false,
      contentType: false,
      type: 'POST',
      success: function (response) {
        console.log('success');
        console.log(response);
      },
      error: function (jqXHR) {
        console.log('error');
      }
    });
  });

ajax로 파일이 정상적으로 upload 되는 것을 확인해볼 수 있습니다.

file length : 65893871
file name : 어떤 과학의 초전자포 08.zip
어떤 과학의 초전자포 08.zip uploaded!


Posted by Y2K
,