Spring Security and AngularJS Part VII

2020. 9. 1. 17:30 Spring Framework/Spring boot

스프링 시큐리티와 앵귤러JS Spring Security and AngularJS

모듈화한 앵귤러 JS 어플리케이션 Modular AngularJS Application

이 섹션에서 우리는 어떻게 스프링 시큐티리와 앵귤러JS로 단일 페이지 어플리케이션을 만드는지 계속 얘기해볼 것이다. 이제 우리는 어떻게하면 클라이언트 코드를 모듈화하는지, 어떻게 하면 앵귤러가 기본값으로 사용하지만 대부분의 사용자가 싫어하는 ("/#/login"같은) 파편화된 표기없이 "멋진" URL 경로를 사용할 수 있는지를 보여줄 것이다. 이 글은 시리즈의 일곱번째 섹션으로 첫번째 섹션부터 어플리케이션의 기초 구성단위를 처음부터 배워가도 된고 아니면 Github의 소스코드를 바로 가봐도 된다. 우리는 이 시리즈의 남은 섹션을 자바스크립트의 미진한 부분을 깔끔하게 정리 해볼 것이다. 동시에 어떻게 하면 스프링 시큐리티와 스프링 부트로 빌드된 백엔드 서버에 포근하게 맞출 수 있는지 보여줄 것이다.

어플리케이션 쪼개기 Breaking up the Application

이 시리즈에서 우리가 지금까지 동작했던 예제 어플리케이션 전체를 단하나의 자바스크립트 소스파일로 때우기에 충분히 사소했다. 더 큰 어플리케이션은 이와 같은 방식으로 가서는 안된다. 이렇게 하나로 시작했을 때조차,  예제에서 실제의 환경에서 동작하는 것처럼 만들어야 하므로 우리는 이들을 나눌것이다. 좋은 시작점은 두번째 섹션에서 만들었던 "단일"어플리케이션을 취해 그 소스코드의 구조를 들여다 보는 것이다. 여기 정적 컨탠트를 위한 디렉토리 리스트가 있다( 서버에 있는 "application.yml"은 제외했다):

static/
 js/
   hello.js
 home.html
 login.html
 index.html

여기에 몇가지 문제점이 있는데 하나는 명확하다: 모든 자바스크립트가 하나의 파일(hello.js)에 있다는 것이다. 또 하나는 감지하기 힘든데: 우리의 어플리케이션 내부의 뷰가 HTML이 "부분적"("login.html"과 "home.html")이지만 이들은 모두 평이한 구조안에 있으며 이들을 사용하는 컨트롤러 코드와 연관되어있지 않다.

자바스크립트를 더 자세히 들여다보자. 우리는 좀 더 관리하기 쉬운 조각들로 나누는 것을 앵귤러가 더 쉽게 만들어주고 있는 것을 볼 수 있다:

hello.js

angular.module('hello', [ 'ngRoute' ]).config(

  function($routeProvider, $httpProvider) {

    $routeProvider.when('/', {
      templateUrl : 'home.html',
      controller : 'home'
    }).when('/login', {
      templateUrl : 'login.html',
      controller : 'navigation'
    }).otherwise('/');

    ...

}).controller('navigation',
    function($rootScope, $scope, $http, $location, $route) {
      ...
}).controller('home', function($scope, $http) {
    ...
  })
});

여기에 약간의 "config" 와 두개의 컨트롤러("home" 과 "navigation")이 있고 컨트롤러는 이 일부분(각각 "html.html"과 "login.html")을 멋지게 맵핑해주고 있다. 이 일부들을 각각 쪼개보자:

static/
  js/
    home/
      home.js
      home.html
    navigation/
      navigation.js
      login.html
    hello.js
  index.html

컨트롤러 정의는 동작하는 데 필요한 HTML과 함께 각각의 모듈안으로 이동하였다. - 멋게 모듈화되었다. 만일 이미지나 커스텀 스타일시트가 필요하면, 우리는 이들과 똑같은 방식으로 만들어주면 된다.

same with those.

 모든 클라이언트 코드는 (index.html 를 제외하고) 단일 디렉토리 밑에 놓는다. 왜냐하면 "welcome" 페이지가 있어 "정적" 디렉토리로부터 자동적으로 불러지기 때문이다. 이것은 의도적으로서 모든 정적 리소스로의 단일 스프링 시큐리티 접근 규칙을 만들기 쉽게 하기 위해서다. 이들은 모두 보호받지 않는다. (스프링 부트 어플리케이션에서는 기본적으로  /js/** 를 보호하지 않기 때문이다), 하지만 다른 어플리케이션을 위해 다른 규칙이 필요하다면 다른 경로를 선택해주면 된다.

예를 들어 여기 home.js를 보자:

code,javascript
angular.module('home', []).controller('home', function($scope, $http) {
    $http.get('/user/').success(function(data) {
        $scope.user = data.name;
    });
});

그리고 여기 새로운 hello.js:

code,javascript
angular
    .module('hello', [ 'ngRoute', 'home', 'navigation' ])
    .config(

        function($routeProvider, $httpProvider) {

          $routeProvider.when('/', {
            templateUrl : 'js/home/home.html',
            controller : 'home'
          }).when('/login', {
            templateUrl : 'js/navigation/login.html',
            controller : 'navigation'
          }).otherwise('/');

          $httpProvider.defaults.headers.common['X-Requested-With'] = 'XMLHttpRequest';

        });

어떻게 "hello" 모듈이 그들을 목록화함으로서 ngRoute와 함께 초기화 선언된 다른 두개를 신뢰하는지 알아두자. 이것을 동작하게 만드려면, 당신은 index.html안에 올바른 순서로 모듈정의를 불러주어야 한다:

...
<script src="js/angular-bootstrap.js" type="text/javascript"></script>
<script src="js/home/home.js" type="text/javascript"></script>
<script src="js/navigation/navigation.js" type="text/javascript"></script>
<script src="js/hello.js" type="text/javascript"></script>
...

이것은 실제 쓰이는 앵귤러 JS 의존성 관리 시스템이다. 다른 프레임워크들도 (단언컨대 더 우수한) 유사한 기능을 가지고 있다. 또한 더 큰 사이즈의 어플리케이션에서, 당신은 모든 자바스크립트를 함께 번들화 하는 빌드하는 절차를 밟아 브라우저가 더 효율적으로 불러올수 있게할 것이다. 그러나 이건 거의 취향의 차이이다.

"자연스러운" 라우트 사용하기 Using "Natural" Routes

앵귤러의 $routeProvider는 기본값으로 URL 경로안에 파편화된 위치탐지자를 가지고 동작한다. 예를 들어 "/login"경로로서 hello.js 안에 라우트되도록 명시한 로그인 페이지는 (브라우저 윈도우에서 당신이 보게되는) 실제 URL에서 "/#/login" 로 해석되어진다. 이는 루트패스 "/"를 통해 불러오는 index.html안의 자바스크립트에서 모든 라우트들을 이렇게 활성화한다. 파편화된 이름은 약간 사용자에 익숙하지않으며 때론 URL 경로가 앵귤러 라우트 선언과 같도록 "자연스럽게" 라우트되도록하는게 더 편리하다. 예를 들어 "/login"에 "/login"을 씀. 만일 당신이 오직 정적 리소스만 가지고 있다면 이렇게 할 수 없다. 왜냐하면 index.html 은 한방향으로만 불러지기 때문이다. 그러나 (프록시나 어떤 서버사이드 로직) 스택안에 어떤 활성화된 컴포넌트가 를 가지고 있다면, 모든 앵귤러 라우트로부터 index.html 를 불러옴으로서 처리할 수 있다.

이 시리즈에서 당신은 스프링 부트를 사용중이므로 물론, 당신은 서버-사이드 로직을 가지고 있다. 간단한 스프링 MVC 컨트롤러를 사용하여 당신은 어플리케이션의 라우트를 원래대로 사용할 수 있다. 이를 위해 당신이 해줘야 하는 일은 서버에 앵귤러를 열거해주는 것이다. 식별자규칙(naming convention)에 의해 이것을 하도록 선택하였다. 마침표를 포함하지 않은 (그리고 명시적으로 이미 맵핑되지않은) 모든 경로는 앵귤러 라우트로서 홈페이지에 보내져야(forward)한다:

@RequestMapping(value = "/{[path:[^\\.]*}")
public String redirect() {
  return "forward:/";
}

이 메소드는 스프링 어플리케이션의 어딘가의 (@RestController이 아니라) @Controller 에서 넣어줘야한다. 우리는 브라우저가 사용자가 실제 URL에서 보는 "실제" 라우트를 기억할 수 있도록 ("redirect가 아니라) "foward"를 사용했다. 비록, 우리가 이 어플리케이션에어 이 장점을 적용하진 않겠지만, 이는 또한 스프링 시큐리티의 인증을 위한 저장된 요청(saved request) 메카니즘이 막 바로 사용가능(out of the box)하도록 동작한다는 것을 의미한다.

 github의 예제코드에 있는 어플리케이션은 추가적인 라우트를 가지고 있어 당신은 더 완전한 기능을 확인할 수 있다. 그러므로 실사용이 가능한 어플리케이션일 것이다. ("/home"과 "/message"는 약간 다른 뷰를 가지는 다른 모듈들이다).

이 "자연스러운" 라우트를 가지는 어플리케이션을 완성시키기위해, 당신은 앵귤러에게 이 두가지 절차를 알려줘야한다. 첫번째는hello.js에서 $locationProvider안의 config함수에 "HTML5모드"설정을 추가해야한다:

angular.module('hello', [ 'ngRoute', 'home', 'navigation' ]).config(

  function($locationProvider, $routeProvider, $httpProvider) {

    $locationProvider.html5Mode(true);
    ...
});

index.html안의 HTML헤더안에 추가적인 <base/> 엘리먼트와 같이 묶어, 당신은 메뉴바의 링크를 파편화("#")를 없애기 위해 바꿔줘야한다:

<html>
<head>
<base href="/" />
...
</head>
<body ng-app="hello" ng-cloak class="ng-cloak">
    <div ng-controller="navigation" class="container">
        <ul class="nav nav-pills" role="tablist">
            <li><a href="/">home</a></li>
            <li><a href="/login">login</a></li>
            <li ng-show="authenticated"><a href="" ng-click="logout()">logout</a></li>
        </ul>
    </div>
...
</html>

앵귤러는<base/> 엘리먼트에 라우트를 정박하고 브라우저에 나타나는 URL들을 쓰기 위해 사용한다. 당신은 스프링 부트 어플리케이션을 동작하고 있으므로 기본 설정은 (8080포트상의) "/" 루트 경로로 부터 제공된다. 만일 같은 어플리케이션의 다른 루트 경로로부터 제공할 필요가 있다면, 그 경로를 서버-사이드 템플릿(많은 사람들이 단일 페이지 어플리케이션을 위해 정적리소스와 함께 쓰기를 원한다 그래서 그들은 정적 루트 경로와 함께 쓰인다)을 사용하여 HTML안으로 랜더해줘야 할 것이다.

인증 관련정보 추출하기 Extracting the Authentication Concerns

위에서 처럼 어플리케이션을 모듈화할 때, 당신은 코드가 그저 모듈들로 나누어져서 작동시킨 것뿐이라는 걸 알수 있을 것이다. 하지만, 사소하게 트집을 잡자면 우리는 여전히 $rootScope를 사용하여 컨트롤러간의 상태를 공유한 다는 점이다. 이것은 이런 작은 규모의 어플리케이션에서는 크게 잘못된 것은 아니다. 이렇게 함으로서 아주 괜찮은 수준의 프로토타입을 아주, 아주 빠르게 만들수 있게 된다. 그러니 이것에 대해 너무 슬퍼할 필요는 없다. 그러나 우리가 각각 분리된 모듈에서 인증관련 모든정보를 추출할 기회가 있다면, 앵귤러 용어에서 당신이 필요한 것을 "서비스"라고 정의한다. 그래서 ("auth"라 부르는) 새로운 모듈을 당신의 "home"과 "navigation" 모듈 다음으로 만들어보자:

static/
  js/
    auth/
      auth.js
    home/
      home.js
      home.html
    navigation/
      navigation.js
      login.html
    hello.js
  index.html

auth.js 코드를 작성하기 전, 우리는 다른 모듈의 수정을 예상할 수 있을 것이다. 먼저 navigation.js 에서 "navigation"모듈을 새로운 "auth"모듈에 의존성을 가지도록 만들어주어야한다. "auth"서비스를 컨트롤러에 주입하자 (물론 $rootScope 은 이제 더이상 필요없다):

angular.module('navigation', ['auth']).controller(
        'navigation',

        function($scope, auth) {

            $scope.credentials = {};

            $scope.authenticated = function() {
                return auth.authenticated;
            }

            $scope.login = function() {
                auth.authenticate($scope.credentials, function(authenticated) {
                    if (authenticated) {
                        console.log("Login succeeded")
                        $scope.error = false;
                    } else {
                        console.log("Login failed")
                        $scope.error = true;
                    }
                })
            };

            $scope.logout = function() {
              auth.clear();
            }

        });

이전의 컨트롤러와 많이 다르지않다 (여전히 사용자 액션, 로그인과 로그아웃을 위한 함수들과 로그인후 credential을 유지하고 있는 객체가 필요하다). 하지만 새 "auth" 서비스로 구현을 추상화하였다. "auth"서비스는 login()을 지원하기 위해 authenticate()함수가, logout()을 지원하기위해 clear() 함수를 필요로한다. 또한 authenticated 플래그가 있어 이전 컨트롤러의 $rootScope.authenticated 를 대체한다. 우리는 컨트롤러의 $scope에 붙여진 같은 이름으로 함수안에서 authenticated 플래그를 사용한다. 따라서 앵귤러가 이 값을 계속 확인하고 사용자가 로그인할때 UI를 업데이트한다.

"auth" 모듈을 재사용가능하게 만들길 원한다는 가정하에, 당신은 여기에 하드코드된 경로를 원하지 않을 것이다. 문제는 없지만, hello.js 모듈안의 경로를 초기화 하거나 설정해줘야할 것이다. 이것을 위해 run() 함수를 추가해보자:

angular
  .module('hello', [ 'ngRoute', 'auth', 'home', 'navigation' ])
  .config(
    ...
  }).run(function(auth) {

    auth.init('/', '/login', '/logout');

});

run() 함수는 "hello" 에 의존하는 어떠한 모듈에서 호출할 수 있다. 이 경우, auth 서비스를 주입하고 각각 홈페이지,로그인, 로그아웃 종단endpoint의 경로로 이것을 초기화해준다.

이제 당신은 index.html안에  다른 모듈뿐만 아니라 "auth" 모듈을 불러와야 한다.("auth"에 의존성이 있는 "login"모듈 전에):

...
<script src="js/auth/auth.js" type="text/javascript"></script>
...
<script src="js/hello.js" type="text/javascript"></script>
...

이렇게 하면 마침내 당신은 위에서 적어둔 세 함수 (authenticate()clear() 그리고 init())를 위한 코드를 작성할 수 있다. 여기 코드가 있다:

angular.module('auth', []).factory(
    'auth',

    function($http, $location) {

      var auth = {

        authenticated : false,

        loginPath : '/login',
        logoutPath : '/logout',
        homePath : '/',

        authenticate : function(credentials, callback) {

          var headers = credentials && credentials.username ? {
            authorization : "Basic "
                + btoa(credentials.username + ":"
                    + credentials.password)
          } : {};

          $http.get('user', {
            headers : headers
          }).success(function(data) {
            if (data.name) {
              auth.authenticated = true;
            } else {
              auth.authenticated = false;
            }
            $location.path(auth.homePath);
            callback && callback(auth.authenticated);
          }).error(function() {
            auth.authenticated = false;
            callback && callback(false);
          });

        },

        clear : function() { ... },

        init : function(homePath, loginPath, logoutPath) { ... }

      };

      return auth;

    });

 (예를 들어 "navigation" 컨트롤러엣 이미 주입된auth 서비스를 위해 "auth" 모듈은 팩토리를 생성한다. 이 팩토리는 단지 (auth) 객체를 돌려주는 함수이고, 이 객체는 3개의 함수를 가져야하고 플래그는 위에 우리가 예상한대로다. 위에서 우리가 "navigation" 컨트롤러안의 예전것과 대체로 같은 authenticate() 함수의 구현을 보여주었다. 이는 백앤드 리소스 "/user"를 호출하여 authenticated 플래그를 설정한다 그리고 그 플래그의 값에 따라 선택적인 콜백을 호출한다. 만일 성공적이면, $location서비스 (바로 아래서 이것을 다룬다)를 사용하여 사용자를 homePath 로 보낸다.

여기 당신이 "auth" 모듈에서 하드코드하는 것을 원하지 않는 다양한 경로들을 설정하는 init() 함수의 기본 뼈대 구현이 있다:

init : function(homePath, loginPath, logoutPath) {
  auth.homePath = homePath;
  auth.loginPath = loginPath;
  auth.logoutPath = logoutPath;
}

다음은 clear() 함수 구현이다 더 간단하다:

clear : function() {
  auth.authenticated = false;
  $location.path(auth.loginPath);
  $http.post(auth.logoutPath, {});
}

이는 authenticated 플래그를 설정해지하고 사용자를 로그인페이지로 되돌려보낸다. 그다음 HTTP POST를 로그아웃 경로로 보낸다. POST 호출은 우리가 여전히 "단일" 어플리케이션으로부터 CSRF 보호기능을 가지고 있기 때문에 성공할 것이다. 만일 403 메세지를 본다면 에러메세지와 서버로그를 확인하고 그다음 보내진 XSRF 쿠키를 확인하는 필터를 확인해보자

거의 마지막 수정은 사용자가 인증하지 않았을경우 "로그아웃" 링크를 숨기기위한 index.html이다:

<html>
...
<body ng-app="hello" ng-cloak class="ng-cloak">
  <div ng-controller="navigation" class="container">
    <ul class="nav nav-pills" role="tablist">
          ...
      <li ng-show="authenticated()"><a href="" ng-click="logout()">logout</a></li>
    </ul>
  </div>
...
</html>

"navigation" 컨트롤러는 "auth" 서비스를 얻기 위해서 그리고  $rootScope에 있지않은 플래그의 값을 찾기 위해서 당신은 그냥 authenticated플래그를 바꿔주고 authenticated()함수를 호출하면 된다.

로그인 페이지로 리다이렉션하기 Redirecting to the Login Page

이제껏 우리가 홈페이지를 구현했던 방법은, (그냥 로그인함으로서) 사용자가 인증받았을 때 보여주는 약간의 컨텐트를 가지고 있는 것이다. 어떤 어플리케이션들은 이 방식으로 동작하고 어떤것은 그렇지않다. 어떤 어플은 다른 사용자 경험을 제공해주어, 사용자가 인증받을 때까지 로그인페이지이외에 아무것도 볼수 없다. 우리도 어떻게하면 어플리케이션에 이 패턴을 가지도록 바꿔보자.

로그인페이지와 함께 모든 컨텐트를 숨기는 건 전통적으로 많은 것에 영향을 미치는 중요한 관심사다:당신은 UI 모듈에 위치한 로그인 페이지를 보여주기 위한 어떠한 로직도 원하지 않는다 ( 이것은 어디서나 중복적어서 코드를 더묵 만들기 힘들게 하고 유지보수가 더 어려워진다). 스프링 시큐리티는 서버에서 이런 많은 것 영향을 미치는 모든 관심사에 대한 것이다. Filters 과 AOP 인터셉터의 정점에서 빌드되었기 때문이다. 불행하게도 이것은 이 단일 페이지 어플리케이션에서는 크게 도움이 되지않는다. 하지만 앵귤러는 우리가 원하는 이러한 패턴의 구현을 쉽게 만들어줄 몇가지 기능을 가지고 있다. 이 기능의 도움을 받아 우리는 "라우트 수정route changes"를 위한 리스너를 설치할 수 있다. 따라서 매번 사용자가 새 라우트로 이동된다 (예를 들어 메뉴바 또는 어떠한 것을 클릭할때), 또는 페이지가 처음으로 로드될때, 당신은 라우스틀 조사하여 필요시 바꿀 수 있다.

리스너를 인스톨하려면, 우리는 auth.init()함수에 추가적인 약간의 코드를 써줘야한다.("hello" 모듈이 불러질때, 동작하도록 이미 준비되었으므로):

angular.module('auth', []).factory(
    'auth',

    function($rootScope, $http, $location) {

      var auth = {

        ...

        init : function(homePath, loginPath, logoutPath) {
          ...
          $rootScope.$on('$routeChangeStart', function() {
            enter();
          });
        }

      };

      return auth;

    });

새로운 enter()함수에 할당된 간단한 리스너를 등록했다. 이제 당신은 "auth" 모듈 팩토리 함수(팩토리 객체 자체에 접근할 수 있는)에 이것을 구현해줘야한다: 

enter = function() { if ($location.path() != auth.loginPath) { auth.path = $location.path(); if (!auth.authenticated) { $location.path(auth.loginPath); } } }

로직은 간단하다: 만일 경로가 로그인 페이지가 아닌 다른 어떠한 경로로 바뀌면 경로값을 기록한다. 그뒤 만일 사용자가 인증되있지않으면 로그인페이지로 간다. 우리가 경로값을 저장하는 이유는 그럼으로서 우리가 성공적인 인증후 이 값으로 돌아갈 수 있기 때문이다. (스프링 시큐리티는 서버쪽의 이 기능을 가지고 있으며 이것은 사용자들에게 아주 멋진 기능이다). 성공 핸들러에 약간의 코드를 추가함으로서 authenticate() 함수안에서 이것을 할 수 있다:

authenticate : function(credentials, callback) { ... $http.get('user', { headers : headers }).success(function(data) { ... $location.path(auth.path==auth.loginPath ? auth.homePath : auth.path); }).error(...); },

인증에 성공하면, 우리는그냥 홈페이지나 가장 최근에 선택했던 경로(로그인 페이지가 아니라면)로 이동하게 설정하면 된다.

마지막 수정할 것중 하나는 사용자 경험을 더욱 획일적으로 만드는 것이다: 우리는 어플리케이션이 처음 시작할때 홈페이지 대신 로그인 페이지를 보여주려고 한다. 당신은 이미 authenticate() 함수안에 (로그인페이지로 리다이렉트하는) 로직을 가지고 있으며, 따라서 당신이 필요한 것은 (사용자가 이미 쿠키를 가지고 있지 않으면 실패하게 되는) 텅빈 credential을 가지고 인증하기 위해 init() 함수에 다음과 같이 약간의 코드를 넣어주는게 전부다:

init : function(homePath, loginPath, logoutPath) {
  ...
  auth.authenticate({}, function(authenticated) {
    if (authenticated) {
      $location.path(auth.path);
    }
  });
  ...
}

auth.path 가 $location.path()을 가지고 초기화 했다면, 사용자가 브라우저에 명시적으로 라우트를 타입해주었을 때도 동작할 것이다 (예를 들어 홈페이지를 먼저 불러오기 싫을 때):

(IDE에서 main() 메소드를 통해 또는 커맨드라인에서 mvn spring-boot:run를 사용하여) 어플리케이션을 시작하고 http://localhost:8080를 방문하고 결과를 확인해보자:

기억하기:  쿠키와 HTTP Basic credential에 대한 브라우저 캐시를 삭제해야한다. 크롬에서 새 incognito 창을 여는게 최선의 방법이다.

결 론 Conclusion

이번 섹션에서 우리는 (이 튜토리얼의 두번째 섹션 의 어플리케이션을 가지고 시작하여) 어떻게 하면 앵귤러 어플리케이션을 모듈화하는지 살펴봤다. 어떻게 로그인 페이지로 리다이렉트하는지, 어떻게 사용자에 의해 쉽게 북마크하고 타입할 수 있게 "자연스럽게" 라우트를 사용하는지. 우리는 이 튜토리얼의 마지막 몇 섹션에서 클라이언트 코드에 좀 더 집중했었고 우리가 섹션III-VI. 에서 만든 분산 아키텍쳐를 잠시 파보았다. 이것이 여기에서 바꾼 것들이 다른 어플리케이션에서 적용할 수 없다는 의미가 아니다. (실제로 이것은 꽤나 사소한것이다) - 단신 우리가 클라이언트 쪽에서 하는 일을 배우는 도안 서버쪽 코드를 간소화했을 뿐이다. 서버사이드 몇몇 기능을 우리는 간단하게 사용했었고 의논하였다 (예를 들면 스프링 MVC에서 "자연스러운" 라우트를 가능하게 하기 위해 뷰를 "forward"했었다) 이렇게 우리는 앵귤러와 스프링을 함께 동작시키자는 주제를 계속 이어왔고 이곳 저곳에 작은 수정을 함으로서 매우 잘 이루어냈다.



출처: https://springboot.tistory.com/10?category=620229 [스프링부트는 사랑입니다]