> Hello World !!!

     

@syaku

Spring security restful authentication and Angularjs : 스프링프레임워크 시큐리티 RESTful 로그인 인증 #1

written by Seok Kyun. Choi. 최석균


Spring security restful authentication and Angularjs : 스프링프레임워크 시큐리티 RESTful 로그인 인증 #1

개발환경

Mac OS X 10.9.4
JAVA 1.6
Apache Tomcat 7.x
Spring 3.1.1
Spring security 3.1.1
Spring Tool Suite 3.5.1
Maven 2.5.1
Angularjs 1.3.0


[2016.04.18] 내용추가

본 포스팅의 내용 일부가 상실된 부분이 있어. 이제서야 소스를 첨부합니다. 본문 내용과는 버전 그리고 일부 설정이 약간다를뿐 크게 들리지않으니 링크한 github에서 소스를 참고하시기 바랍니다. (로그인 암호: 1234)

https://github.com/syakuis/syaku-gradle/tree/Spring-security-restful-authentication-and-Angularjs


Spring security 를 활용하여 비동기? 로그인 처리를 구현하려고 한다. 일반적인 인증방식을 구현한다면 시큐리티 포스팅 #4 까지의 내용으로도 무리가 없다. 하지만 인증방식이 좀 다른 Restful 환경에서 회원 인증을 어떤 방식으로 하고, 개발되는 지 알아보기로 한다.

난 예전에 OAuth2.0 를 이용한 sns 인증시스템을 구현한 적이 있어 이 방식을 이해하는 데 좀 수월했다.
OAuth2.0 인증 방식을 사용하는 것은 아니지만, 약간 개념은 비슷하기 때문이다.

우선 웹서버 환경을 설명하겠다. Restful 환경이라 프론트와 백엔드가 분리되어 있다. 프론트는 Nginx 서버로 구동하고 백단은 STS 에서 제공하는 톰캣서버이다. 그리고 둘을 연동하였다.

프론트 프로그램은 Angularjs 사용하였고 백엔드는 당연히 스프링 프레임워크이다.
서버환경에 대한 설명은 이쯤하고, 인증방식에 대해 설명하겠다. OAuth2.0 만큼 복잡하지 않다~

  1. 로그인하지 않고 권한이 없는 페이지에 접근하거나 로그인 페이지를 요청하면 로그인 폼으로 이동한다.
    <form-login login-page="" ... />
    
  2. 로그인 폼에서 계정과 암호를 입력하고 로그인 한다.

    <form-login login-processing-url="" ... />
    <authentication-manager alias="authenticationManager"> ...
    
  3. 입력한 계정이나 암호가 틀릴 경우 에러 헨들러를 호출한다.

    <beans:bean id="signinFailureHandler" class="" />
    
  4. 로그인에 성공하면 성공 헨들러를 호출한다.

    <beans:bean id="signinSuccessHandler" class="" />
    

    이때 생성한 인증토큰을 프론트에 전달한다. 전달받은 인증토큰을 쿠키에 저장하고 ajax 즉 $http(Angularjs 사용하는 오프젝트) 를 사용할 때마다 헤더에 인증토큰을 넣어서 백엔드를 요청한다.

  5. 프론트에서 백앤드로 요청이 들어올때마다 인증을 체크할 수 있게 필터를 추가한다.

    <http .... >
    <custom-filter ref="authenticationTokenProcessingFilter" before="FORM_LOGIN_FILTER" />
    </http>
    

    로그인 처리하기 전에 before 필터를 이용하여 먼저 인증 처리를 거치게 한다.
    인증토큰이 있을 경우 자동으로 인증을 처리하고 없을 경우 예외가 발생되거나 접근 실패 헨들러를 호출한다.

    <http ....>
    <access-denied-handler ref="accessFailureHandler" />
    </http>
    

    이런 식으로 인증사이클이 이루어진다. 위 방법은 여러 사이트를 참고하여 구현한 것이니, 보안에 대한 지식이 많지 않아서 안전하다고 장담할 수 없다…

프론트와 백엔드 모두를 개발하다 보니 소스량이 많다. 핵심적인 부분만 설명할 것이고, 이전에 설명했던 부분도 생략할 것이다.필요하다면 첨부한 소스를 참고하여 개발한다.

Spring

@소스 servlet-context.xml

<?xml version="1.0" encoding="UTF-8"?>
<beans:beans xmlns="http://www.springframework.org/schema/mvc"
    xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
    xmlns:beans="http://www.springframework.org/schema/beans"
    xmlns:context="http://www.springframework.org/schema/context"
    xmlns:security="http://www.springframework.org/schema/security"
    xsi:schemaLocation="
        http://www.springframework.org/schema/mvc http://www.springframework.org/schema/mvc/spring-mvc.xsd
        http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans.xsd
        http://www.springframework.org/schema/context http://www.springframework.org/schema/context/spring-context.xsd
        http://www.springframework.org/schema/security http://www.springframework.org/schema/security/spring-security.xsd
        ">

    <!-- DispatcherServlet Context: defines this servlet's request-processing infrastructure -->

    <!-- Enables the Spring MVC @Controller programming model -->
    <annotation-driven />

    <!-- Handles HTTP GET requests for /resources/** by efficiently serving up static resources in the ${webappRoot}/resources directory -->
    <resources mapping="/resources/**" location="/resources/" />

    <!-- Resolves views selected for rendering by @Controllers to .jsp resources in the /WEB-INF/views directory -->
    <beans:bean class="org.springframework.web.servlet.view.InternalResourceViewResolver">
        <beans:property name="prefix" value="/WEB-INF/views/" />
        <beans:property name="suffix" value=".jsp" />
    </beans:bean>

    <context:component-scan base-package="com.syaku.rest" use-default-filters="false">
    <context:include-filter expression="org.springframework.stereotype.Controller" type="annotation" />
    </context:component-scan>

    <security:global-method-security pre-post-annotations="enabled" />

</beans:beans>

컨트롤러만 스캔될 수 있게 설정하였고 어노테이션 권한 설정을 할 수 있게 설정하였다.

@소스 security-context.xml

<?xml version="1.0" encoding="UTF-8"?>
<beans:beans 
    xmlns="http://www.springframework.org/schema/security"
    xmlns:beans="http://www.springframework.org/schema/beans"
    xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
    xmlns:context="http://www.springframework.org/schema/context"
    xsi:schemaLocation="
    http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans.xsd
    http://www.springframework.org/schema/security http://www.springframework.org/schema/security/spring-security.xsd
    http://www.springframework.org/schema/context http://www.springframework.org/schema/context/spring-context.xsd
    ">

    <context:component-scan base-package="com.syaku.rest..." use-default-filters="false">
    <context:include-filter expression="org.springframework.stereotype.Service" type="annotation" />
    <context:include-filter expression="org.springframework.stereotype.Repository" type="annotation" />
    </context:component-scan>


    <http auto-config="true" use-expressions="true" create-session="never" entry-point-ref="unauthorizedEntryPoint" >

        <form-login
            username-parameter="user_id" 
            password-parameter="password"
            login-processing-url="/signin_proc"
            authentication-success-handler-ref="signinSuccessHandler"
            authentication-failure-handler-ref="signinFailureHandler"
            default-target-url="/mypage"
            always-use-default-target="false"
            />

        <logout
        invalidate-session="true"
        logout-url="/signout" />
        <access-denied-handler ref="accessFailureHandler" />

        <custom-filter ref="authenticationTokenProcessingFilter" before="FORM_LOGIN_FILTER" />
    </http>
    <beans:bean id="unauthorizedEntryPoint" class="com.syaku.rest.user.handler.UnauthorizedEntryPoint">
        <beans:property name="loginFormUrl" value="/signin"/>
    </beans:bean>
    <beans:bean id="authenticationTokenProcessingFilter" class="com.syaku.rest.user.AuthenticationTokenProcessingFilter" />

    <beans:bean id="signinSuccessHandler" class="com.syaku.rest.user.handler.SigninSuccessHandler" />
    <beans:bean id="signinFailureHandler" class="com.syaku.rest.user.handler.SigninFailureHandler" />

    <beans:bean id="accessFailureHandler" class="com.syaku.rest.user.handler.AccessFailureHandler">
        <beans:property name="errorPage" value="/error" />
    </beans:bean>

    <beans:bean id="userService" class="com.syaku.rest.user.model.UserService" />
    <beans:bean id="customAuthenticationProvider" class="com.syaku.rest.user.CustomAuthenticationProvider"/>

    <beans:bean id="passwordEncoder" class="org.springframework.security.authentication.encoding.ShaPasswordEncoder"/>
    <beans:bean id="saltSource" class="org.springframework.security.authentication.dao.ReflectionSaltSource">
    <beans:property name="userPropertyToUse" value="username" />
    </beans:bean> 

    <authentication-manager alias="authenticationManager">
        <authentication-provider ref="customAuthenticationProvider" />
        <authentication-provider user-service-ref="userService">
            <password-encoder ref="passwordEncoder">
                <salt-source ref="saltSource" />
            </password-encoder>
        </authentication-provider>
    </authentication-manager>

</beans:beans>

create-session 속성으로 세션을 생성할 수 한다.
entry-point-ref 로그인 페이지를 커스텀하게 구현하였다.
custom-filter 모든 페이지에 인터셉터가 실행되면서 인증을 체크할 수 있게 설정하였다.

그외 설정들은 이전 포스팅에서 사용되었던 것들이라 생략한다.

@소스 UnauthorizedEntryPoint.java

package com.syaku.rest.user.handler;

import java.io.IOException;
import java.io.PrintWriter;

import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;

import org.apache.commons.lang.StringUtils;
import org.codehaus.jackson.map.ObjectMapper;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.AuthenticationEntryPoint;
import org.springframework.stereotype.Component;

import com.syaku.rest.commons.ResponseResult;

@Component
public class UnauthorizedEntryPoint implements AuthenticationEntryPoint {

    private String loginFormUrl;

    public String getLoginFormUrl() {
        return loginFormUrl;
    }

    public void setLoginFormUrl(String loginFormUrl) {
        this.loginFormUrl = loginFormUrl;
    }

    public UnauthorizedEntryPoint() {

    }

    public UnauthorizedEntryPoint(String loginFormUrl) {
        this.loginFormUrl = loginFormUrl;
    }


    @Override
    public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException {
        String accept = request.getHeader("accept");

        if( StringUtils.indexOf(accept, "html") > -1 ) {
            response.sendRedirect(loginFormUrl);
            //response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "Unauthorized: Authentication token was either missing or invalid.");
        } else {
            response.setContentType("application/json");
            response.setCharacterEncoding("utf-8");
            ResponseResult responseError = new ResponseResult();

            if( StringUtils.indexOf(accept, "json") > -1 ) {
                response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
                responseError.setMessage("Unauthorized");
                responseError.setStatus_code("401");
            } else {
                response.setStatus(HttpServletResponse.SC_UNSUPPORTED_MEDIA_TYPE);
                responseError.setMessage("415 Unsupported Media Type");
            }

            ObjectMapper objectMapper = new ObjectMapper();
            String data = objectMapper.writeValueAsString(responseError);
            PrintWriter out = response.getWriter();
            out.print(data);
            out.flush();
            out.close();
        }
    }

}

비동기로 호출되기 때문에 로그인 페이지로 이동해야할 경우 백엔드에서 받아 어떻게 처리할지를 결정할 수 있게 한다.

@소스 AuthenticationTokenProcessingFilter.java

package com.syaku.rest.user;

import java.io.IOException;

import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.ServletRequest;
import javax.servlet.ServletResponse;
import javax.servlet.http.HttpServletRequest;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.GenericFilterBean;

import com.syaku.rest.user.AuthenticationTokenProcessingFilter;
import com.syaku.rest.user.TokenUtils;
import com.syaku.rest.user.model.UserService;
import com.syaku.rest.user.model.domain.User;

@Component
public class AuthenticationTokenProcessingFilter extends GenericFilterBean {
    private static final Logger logger = LoggerFactory.getLogger(AuthenticationTokenProcessingFilter.class);

    @Autowired
    UserService userService;

    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
        HttpServletRequest httpRequest = this.getAsHttpRequest(request);

        String authToken = this.extractAuthTokenFromRequest(httpRequest);
        String userName = TokenUtils.getUserNameFromToken(authToken);

        if (userName != null) {

            User user = userService.loadUserByUsername(userName);

            if (TokenUtils.validateToken(authToken, user)) {

                UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(user, null, user.getAuthorities());
                authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(httpRequest));
                SecurityContextHolder.getContext().setAuthentication(authentication);
            }
        }

        chain.doFilter(request, response);
    }


    private HttpServletRequest getAsHttpRequest(ServletRequest request) {
        if (!(request instanceof HttpServletRequest)) {
            throw new RuntimeException("Expecting an HTTP request");
        }

        return (HttpServletRequest) request;
    }


    private String extractAuthTokenFromRequest(HttpServletRequest httpRequest) {
        String authToken = httpRequest.getHeader("X-Auth-Token");
        return authToken;
    }
}

Restful 요청될때마다 X-Auth-Token 헤더값을 읽어서 인증값이 있는 경우 인증처리한다.

@소스 TokenUtils.java

package com.syaku.rest.user;

import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.security.crypto.codec.Hex;

import com.syaku.rest.user.model.domain.User;

public class TokenUtils {
    private static final Logger logger = LoggerFactory.getLogger(TokenUtils.class);
    public static final String MAGIC_KEY = "syaku_token";

    public static String createToken(User userDetails) {

        // 인증토크 유효시간.
        long expires = System.currentTimeMillis() + 1000L * 60 * 60;

        StringBuilder tokenBuilder = new StringBuilder();
        tokenBuilder.append(userDetails.getUsername());
        tokenBuilder.append(":");
        tokenBuilder.append(expires);
        tokenBuilder.append(":");
        tokenBuilder.append(TokenUtils.computeSignature(userDetails, expires));

        return tokenBuilder.toString();
    }


    public static String computeSignature(User userDetails, long expires)
    {
        StringBuilder signatureBuilder = new StringBuilder();
        signatureBuilder.append(userDetails.getUsername());
        signatureBuilder.append(":");
        signatureBuilder.append(expires);
        signatureBuilder.append(":");
        signatureBuilder.append(userDetails.getPassword());
        signatureBuilder.append(":");
        signatureBuilder.append(TokenUtils.MAGIC_KEY);

        MessageDigest digest;
        try {
            digest = MessageDigest.getInstance("MD5");
        } catch (NoSuchAlgorithmException e) {
            throw new IllegalStateException("No MD5 algorithm available!");
        }

        return new String(Hex.encode(digest.digest(signatureBuilder.toString().getBytes())));
    }


    public static String getUserNameFromToken(String authToken)
    {
        if (null == authToken) {
            return null;
        }

        String[] parts = authToken.split(":");
        return parts[0];
    }


    public static boolean validateToken(String authToken, User userDetails)
    {
        String[] parts = authToken.split(":");
        long expires = Long.parseLong(parts[1]);
        String signature = parts[2];

        if (expires < System.currentTimeMillis()) {
            logger.info(expires + " : 생성시간이 잘못됨...." + System.currentTimeMillis());
            return false;
        }

        return signature.equals(TokenUtils.computeSignature(userDetails, expires));
    }
}

인증토큰을 생성하고, 검사하는 역활을 한다. MAGIC_KEY 는 임의적인 값을 넣으면된다. expires 인증토큰을 유효시간이며 해당 시간이지나면 로그아웃된다.

이제 로그인 요청과 인증값을 헤더로 던져주는 Angularjs 구현해보자.

Angularjs

@소스 index.html

<!DOCTYPE html>
<html ng-app="syakuApp">
<head>
    <meta charset="utf-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <title>Angularjs Test</title>

    <script src="//code.jquery.com/jquery-2.1.1.min.js"></script>

    <link href="//maxcdn.bootstrapcdn.com/bootstrap/3.3.0/css/bootstrap.min.css" rel="stylesheet">
    <link href="//maxcdn.bootstrapcdn.com/font-awesome/4.2.0/css/font-awesome.min.css" rel="stylesheet">
    <!--[if lt IE 9]>
    <script src="//oss.maxcdn.com/html5shiv/3.7.2/html5shiv.min.js"></script>
    <script src="//oss.maxcdn.com/respond/1.4.2/respond.min.js"></script>
    <![endif]-->
    <script src="//maxcdn.bootstrapcdn.com/bootstrap/3.3.0/js/bootstrap.min.js"></script>

    <script src="//code.angularjs.org/1.3.1/angular.min.js"></script>
    <script src="//code.angularjs.org/1.3.1/angular-route.min.js"></script>
    <script src="//code.angularjs.org/1.3.1/angular-resource.min.js"></script>
    <script src="//code.angularjs.org/1.3.1/angular-cookies.min.js"></script>

    <script src="./js/app.js"></script>
    <script src="./js/controllers.js"></script>

    <link href="./jumbotron-narrow.css" rel="stylesheet">
</head>
<body>
    <div class="container">

    <!-- 오류 메세지 -->
    <div ng-show="alert.error">
    <div class="alert alert-warning alert-dismissible" role="alert">
    <button type="button" class="close" ng-click="fnAlertClose()"><span aria-hidden="true">&times;</span><span class="sr-only">Close</span></button>
    {{alert.message}}
    </div>
    </div>

      <div class="header">
        <ul class="nav nav-pills pull-right">
          <li ng-class="{active: fnIsActive('/')}"><a href="#/">Home</a></li>
          <li ng-class="{active: fnIsActive('/signin')}" ng-if="access_token === undefined"><a href="#/signin">Sign In</a></li>
          <li ng-class="{active: fnIsActive('/mypage')}" ng-if="access_token !== undefined"><a href="#/mypage">My Page</a></li>
          <li ng-if="access_token !== undefined"><a href="" ng-click="fnSignOut()">Sign Out</a></li>
        </ul>
        <h3 class="text-muted">Syaku Project</h3>
      </div>

        <!-- 라우터 페이지 -->
        <div ng-view></div>

      <div class="footer">
        <p>&copy; Company 2014</p>
      </div>

    </div> <!-- /container -->

</body>
</html>

저장된 쿠기가 존재할 경우 로그아웃과 마이페이지 버튼이 노출되고 그렇지 않을 경우 로그인 버튼만 노출되게 된다.

@소스 app.js

var syakuApp = angular.module('syakuApp', [
    'AppControllers',
    'ngRoute',
    'ngResource',
    'ngCookies'
]);

// config : 모듈 로드 에서 수행 해야 할 작업 을 등록 하기 위해 이 방법을 사용합니다.
syakuApp.config(['$routeProvider', '$httpProvider', '$locationProvider',
    function($routeProvider, $httpProvider, $locationProvider) {
        // route
        $routeProvider.

            when('/', {
                templateUrl : './partials/index.html',
                controller : 'IndexCtrl'
            }).

            when('/signin', {
                templateUrl : './partials/signin.html',
                controller : 'SigninCtrl'
            }).

            when('/mypage', {
                templateUrl : './partials/mypage.html',
                controller : 'MypageCtrl'
            }).

            otherwise({
                redirectTo : '/'
            });

        $httpProvider.interceptors.push(function ($q, $rootScope, $location) {
            return {

                'request' : function(config) {
                    // 요청할때마다 인증토큰을 헤더에 포함한다.
                    config.headers['X-Auth-Token'] = $rootScope.access_token;

                    return config || $q.when(config);
                },

                'responseError' : function(rejection) {
                    var data = rejection.data;

                    $rootScope.alert(data.error, data.message);

                    return $q.reject(rejection);
                }
            };
        });
    }
]);

syakuApp.run(['$rootScope', '$cookieStore', '$location',
    function($rootScope, $cookieStore, $location) {

        $rootScope.$on('$routeChangeSuccess', function() {
            $rootScope.alert.error = false;
            $rootScope.access_token = $cookieStore.get('access_token');
        });

        $rootScope.alert = function(error, message) {
            $rootScope.alert.error = error;
            $rootScope.alert.message = message;
        }

        $rootScope.fnAlertClose = function() {
            $rootScope.alert.error = false;
        }

        // 로그아웃
        $rootScope.fnSignOut = function() {
            // 인증토큰을 삭제한다.
            delete $rootScope.access_token;
            $cookieStore.remove('access_token');
            $location.path('/');
        }

        $rootScope.fnIsActive = function(route) {
            return route === $location.path();
        }


    }
]);

syakuApp.directive('ngEnter', function () {
    return function (scope, element, attrs) {
        element.bind("keydown keypress", function (event) {
            if(event.which === 13) {
                scope.$apply(function (){
                    scope.$eval(attrs.ngEnter);
                });

                event.preventDefault();
            }
        });
    };
});

$httpProvider.interceptors.push 를 이용하여 모든 ajax 요청에 실행될 수 있게 설정하였다.

@소스 controllers.js

var appControllers = angular.module('AppControllers',[ ]);

appControllers.controller('IndexCtrl', ['$scope', '$route', '$location', '$http', 
    function($scope, $route, $location, $http) {
        var request = $http({
                method : 'GET',
                url : '/rest/',
                header : {'accpet' : 'application/json' }
            });

        request.success(function(data) {
        });

    }
]);

appControllers.controller('SigninCtrl', ['$rootScope', '$scope', '$route', '$http', '$cookieStore', '$location',
    function($rootScope, $scope, $route, $http, $cookieStore, $location) {

        $scope.error = $route.current.params.error;
        if ($scope.error) {
            $rootScope.alert(true, '로그인 하셔야 합니다.');
        }

        $scope.fnSignin = function(user) {
            if ($scope.form.$invalid) return;

            var request = $http({
                method : 'POST',
                url : '/rest/signin_proc',
                params : user
            });

            request.success(function(data) {
                // 로그인 성공하면 인증토큰을 쿠키로 저장한다.
                var access_token = data.data.access_token;
                $cookieStore.put('access_token', access_token);
                $location.url('/');
            });
        };
    }
]);

appControllers.controller('MypageCtrl', ['$scope', '$route', '$location', '$http',
    function($scope, $route, $location, $http) {

        $scope.user = { };        

        var request = $http({
            method : 'GET',
            url : '/rest/mypage',
            header : {'accpet' : 'application/json' }
        });

        request.success(function(data) {
            $scope.user = data;
        });

        request.error(function() {
            $location.url('/signin?error=true');
        });

    }
]);

전체소스 : https://github.com/syakuis/Spring-security-restful-authentication-and-Angularjs

posted syaku blog

Syaku Blog by Seok Kyun. Choi. 최석균.

http://syaku.tistory.com