Spring SecurityをAngularJSに組合せてみる


# はじめに

Spring BootとAngular JSの簡単なWebアプリケーションに、Spring Securityを組み込んでみます。

## 参考

元ネタです。
Spring Security and Angular JS
https://spring.io/guides/tutorials/spring-security-and-angular-js/

# 前提条件

下の記事の後の状態を前提にしています
[Spring BootとAngularUI UI-Routerを使ってみる](http://qiita.com/quwahara/items/658078100c2597cab30c)

# 環境

JVM:          1.8.0_45 (Oracle Corporation 25.45-b02)
OS:           Mac OS X 10.11.3 x86_64

## Spring Tool Suite

Version: 3.7.3.RELEASE
Build Id: 201602250940
Platform: Eclipse Mars.2 (4.5.2)
(こちらから入れました https://spring.io/tools/sts/all)

Buildship: Eclipse Plug-ins for Gradle	1.0.13.v20160411-1723
(Help -> Eclipse marketplaceから入れました)

# 手順

## Spring Securityを追加する

Spring Securityが使えるように、ビルドスクリプトの依存物として、// Addを追記します。

```groovy:build.gradle
dependencies {
	compile 'org.webjars:jquery:2.2.3'
	compile 'org.webjars:angularjs:1.5.3'
	compile 'org.webjars:angular-ui-router:0.2.18'
	compile 'org.webjars:bootstrap:3.3.6'

	compile('org.springframework.boot:spring-boot-starter-web')
	// Add
	compile('org.springframework.boot:spring-boot-starter-security')
	testCompile('org.springframework.boot:spring-boot-starter-test') 
}
```

### 参考

Spring IO Platform Reference Guide
V. Appendices
A. Dependency versions
http://docs.spring.io/platform/docs/2.0.3.RELEASE/reference/htmlsingle/#appendix-dependency-versions

追記を反映するために、Package ExplorerでSSP37(プロジェクトルート)を選び右クリック、
Gradle -> Refresh Gradle Project
をクリックします。
Package ExplorerのProject and External Dependenciesに、追記したJarあるか確認します。

## Basic認証がかかったか確認する

Spring Securityが自動的に、Securityをかけてくれたか確認しましょう。
Webアプリケーションを再起動します。
http://localhost:8080/index.html を開きます。
Basic認証ダイアログが表示されるはずですが、いかかでしょうか。

![スクリーンショット 2016-04-15 10.26.34.png](https://qiita-image-store.s3.amazonaws.com/0/77421/a2802e9d-00ab-89c2-b636-a9f71d6644fa.png)

ユーザー名にuser、パスワードに起動時ログに出力されるパスワードを入れ、ログインします。

起動時ログのパスワード例
![スクリーンショット 2016-04-15 10.26.15.png](https://qiita-image-store.s3.amazonaws.com/0/77421/fb5f814f-38d3-86fb-ece5-38a63d112aed.png)

画面が表示されればOKです。
表示されない場合は、ブラウザキャッシュクリアや、シークレットウィンドウでの表示などを試してみて下さい。

## ログインフォームで認証する

より汎用性のあるSecurityとして、ログインフォームでの認証を行ってみましょう。
これは下の記事を元にしています。

> \[TUTORIAL\] Spring Security and Angular JS
> The Login Page
> https://spring.io/guides/tutorials/spring-security-and-angular-js/#_the_login_page_angular_js_and_spring_security_part_ii

### 静的リソースを公開する

現状ではJavaScript LibraryやHtml Templateなどの静的リソースが、Spring Securityによって保護された状態になっています。
ログインフォームを表示するにも、これらの静的リソースが必要です。
ログイン前から静的リソースにアクセスできるように、Spring Securityの保護から外す設定をしましょう。
下の// Addのように設定Classを追記します。これでwebjarsやsrc/main/resources/static下にある静的リソースへのアクセスを許可しました。それ以外へのアクセスは保護されています。

```java:src/main/java/com/example/Ssp37Application.java
@SpringBootApplication
@Controller
public class Ssp37Application {
    // Omit
    // Add
    @Configuration
    @Order(SecurityProperties.ACCESS_OVERRIDE_ORDER)
    protected static class SecurityConfiguration extends WebSecurityConfigurerAdapter {
        @Override
        protected void configure(HttpSecurity http) throws Exception {
            http
                .httpBasic()
            .and()
                .authorizeRequests()
                    .antMatchers("/webjars/**", "/js/**", "/templates/**", "/index.html", "/").permitAll()
                    .anyRequest().authenticated();
        }
    }
}
```

### ログインフォームの認証先になるendpointをRestControllerに作る

サーバ側のMyRestControllerに、認証のための/userというendpointを追加します。

```java:src/main/java/com/example/controllers/MyRestController.java
@RestController
public class MyRestController {
    // Omit
    // Add
    @RequestMapping("/user")
    public Principal user(Principal user) {
        return user;
    }

}
```

この/userを使った認証は次のような手順を踏みます。
まずログインフォームは/userへ、認証のためにGetリクエストします。
そのリクエストヘッダーに、ユーザー名とパスワードを含めます。
サーバ側でそのリクエストは、/userに対応付けられたuser()メソッドよりも前に、Spring Securityが捕捉します。
Spring Securityはリクエストヘッダーのユーザー名とパスワードを確認し、認められないものならば、401 (Unauthorized)を応答します。user()メソッドに処理が渡りません。
認められるものならば、現在認証されているuser情報を引数に、user()メソッドに処理が渡ります。
そしてuser()メソッドでそのまま、user情報を200 (OK)とともに、応答します。

### 認証を行うauthをfactoryに作る

ブラウザ側にはlogin/logout処理と、その処理結果の状態を持つauth(サービス)を作りましょう。
loginでは先ほどの/userにアクセスします。その際、リクエストヘッダーに、ユーザー名とパスワードを含めます。成功したら認証済としてauthenticatedをtrueにし、ユーザー情報をprincipalに持ちます。
logoutではSpring Securityが用意しているlogout用のendpointにアクセスして、ログイン情報を破棄します。

```js:src/main/resources/static/js/app.js
angular.module('ssp37App', ['ui.router'])
    // Omit
    // Add
    .factory('auth', function($http, $q) {
        return {
            authenticated: false,
            principal: {},
            login: function(credentials) {
                var self = this
                var headers = credentials
                ? { authorization : "Basic " + btoa(credentials.username + ":" + credentials.password) }
                : {};
                return $http.get('/user', {headers : headers})
                .then(function(response) {
                    self.authenticated = true
                    self.principal = response.data
                    return response;
                })
                .catch(function(response) {
                    self.authenticated = false
                    self.principal = {}                    
                    return $q.reject(response);
                });
            },
            logout: function() {
                var self = this
                return $http.post("/logout", {})
                .finally(function() {
                    self.authenticated = false
                    self.principal = {}                    
                });
            }
        };
    })
    // Omit
    ;
```

### stateのマッピングやauthを利用するControllerの作成

まずURLがどのstateにも当てはまらないときの、既定値としてotherwise("/login")を指定しました。
またBasic認証ダイアログを抑制するために、X-Requested-Withをヘッダーに付与するようにしています。
stateにはloginとlogoutを導入し、それぞれControllerを関連付けています。
LoginCtrlでは、ログイン処理の状態を持つ項目と、auth.loginを呼び出して結果を状態に反映する関数を、$scopeに作っています。
auth.login呼び出しで認証されると、hello stateへ遷移します。
LogoutCtrlでは、auth.logoutを呼び出し、その後、login stateへ遷移するようにしています。

```js:src/main/resources/static/js/app.js
angular.module('ssp37App', ['ui.router'])
    .config(function($stateProvider, $urlRouterProvider, $httpProvider) {
        // Change
        $urlRouterProvider.otherwise("/login")
        $stateProvider
            // Add
            .state('login', {
                url: "/login",
                templateUrl: "templates/login.html",
                controller: "LoginCtrl"
            })
            // Add
            .state('logout', {
                url: "/logout",
                controller: "LogoutCtrl"
            })
            // Omit
            ;
        // Add
        $httpProvider.defaults.headers.common["X-Requested-With"] = 'XMLHttpRequest';
    })
    // Omit
    // Add
    .controller('LoginCtrl', function($scope, $state, auth) {
        $scope.error = false
        $scope.authenticated = auth.authenticated
        $scope.credentials = { username: "", password: "" }
        $scope.login = function(credentials) {
            return auth.login(credentials)
            .finally(function() {
                $scope.error = !auth.authenticated
                $scope.authenticated = auth.authenticated
                if($scope.authenticated) {
                    $state.go("hello")
                }
            });
        }
    })
    // Add
    .controller('LogoutCtrl', function($state, auth) {
        auth.logout()
        .finally(function() {
            $state.go("login")
        });
    })
    // Omit
    ;
```

### ログインフォームを作る

ログインフォームはLoginCtrlで\$scopeに作った項目と関連しています。
Loginボタンを押すと、\$scope.login関数がユーザー名とパスワードを持つcredentialsを引数に呼び出され、auth.loginを行います。
もし認証が失敗したら、ng-showディレクティブを持つ\<div\>タグが有効になり、エラーメッセージが表示されます。

```html:src/main/resources/static/templates/login.html
<div class="alert alert-danger" ng-show="error">
    There was a problem logging in. Please try again.
</div>
<form role="form" ng-submit="login(credentials)">
    <div class="form-group">
        <label for="username">Username:</label>
        <input type="text" class="form-control" id="username" name="username" ng-model="credentials.username"/>
    </div>
    <div class="form-group">
        <label for="password">Password:</label>
        <input type="password" class="form-control" id="password" name="password" ng-model="credentials.password"/>
    </div>
    <button type="submit" class="btn btn-primary">Login</button>
</form>
```

### ナビゲーションにlogin/logoutを追加する

ナビゲーションの項目にlogin/logoutを追加しました。

```html:src/main/resources/static/index.html
<html ng-app="ssp37App">
<head>
<meta charset="utf-8">
<title>SSP37</title>
<link rel="stylesheet" href="webjars/bootstrap/3.3.6/css/bootstrap.min.css">
</head>
<body>
	<div class="container">
		<!-- Change -->
		<ul class="nav nav-pills" role="tablist" ng-controller="NavCtrl">
			<!-- Add -->
			<li><a ui-sref="login"  ng-hide="auth.authenticated">login</a></li>
			<!-- Add -->
			<li><a ui-sref="logout"	ng-show="auth.authenticated">logout</a></li>
			<li><a ui-sref="hello"	>hello</a></li>
			<li><a ui-sref="blue"  	>blue</a></li>
			<li><a ui-sref="green"	>green</a></li>
		</ul>
	</div>
	<div ui-view class="container"></div>
	<script src="webjars/jquery/2.2.3/jquery.min.js"></script>
	<script src="webjars/bootstrap/3.3.6/js/bootstrap.min.js"></script>
	<script src="webjars/angularjs/1.5.3/angular.min.js"></script>
	<script src="webjars/angular-ui-router/0.2.18/angular-ui-router.min.js"></script>
	<script src="js/app.js"></script>
</body>
</html>
```

またそのlogin/logoutの表示を切替える、NavCtrlを追加しました。

```js:src/main/resources/static/js/app.js
angular.module('ssp37App', ['ui.router'])
    // Omit
    // Add
    .controller('NavCtrl', function($scope, auth) {
        $scope.auth = auth
    })
    // Omit
    ;
```

### 動作確認する

この段階でlogin-\>hello表示-\>logoutができるはずなので試してみましょう。
http://localhost:8080/index.html を開きます。
ログインフォームがでるでしょうか。
Basic認証のときと同じように、ユーザー名にuser、パスワードに起動時ログに出力されるパスワードを入れ、ログインします。
helloに遷移します。
次にlogoutをクリックします。
すると403 (Forbidden)のエラーになってしまいます。
これはSpring Securityが既定で、logoutへのPostをCSRFから保護しているためです。
この保護に従って動作するようにしましょう。

### CSRF Tokenを取り扱い方

CSRFの保護は次のようになっています。
サーバ側でTokenを生成し、Cookieでブラウザに渡します。
ブラウザはPostをする時に、そのTokenをヘッダーに入れて返却します。
Postを受け取ったサーバは、そのTokenを見て渡したときと同値か確認することで、保護しています。
AngularJSでは既定でこの動作を提供しています。
クッキー名、XSRF-TOKENを受け取ると、その値をヘッダー名、X-XSRF-TOKENで返却してくれます。
サーバ側からそのような名前のクッキーでTokenを送信し、ヘッダーから受け取るようにしましょう。

### XSRF-TOKENクッキーを送る

Spring SecurityはCSRFのTokenを、CsrfFilterでrequestのattributeに用意してくれています。
そのCsrfFilterの後に続くように、クッキーを送るFilterを追加します。（addFilterAfter()）
クッキーを送るFilterの中で、そのTokenをCookieに入れます。(csrfHeaderFilter())

```java:src/main/java/com/example/Ssp37Application.java
@SpringBootApplication
@Controller
public class Ssp37Application {
    // Omit
    @Configuration
    @Order(SecurityProperties.ACCESS_OVERRIDE_ORDER)
    protected static class SecurityConfiguration extends WebSecurityConfigurerAdapter {
        @Override
        protected void configure(HttpSecurity http) throws Exception {
            http
                .httpBasic()
            .and()
                .authorizeRequests()
                    .antMatchers("/webjars/**", "/js/**", "/templates/**", "/index.html", "/").permitAll()
                    .anyRequest().authenticated()
            .and()
                // Add
                .addFilterAfter(csrfHeaderFilter(), CsrfFilter.class)
            ;
        }

        // Add
        private Filter csrfHeaderFilter() {
            return new OncePerRequestFilter() {
                @Override
                protected void doFilterInternal(HttpServletRequest request,
                        HttpServletResponse response, FilterChain filterChain)
                        throws ServletException, IOException {
                    CsrfToken csrf = (CsrfToken) request.getAttribute(CsrfToken.class
                            .getName());
                    if (csrf != null) {
                        Cookie cookie = WebUtils.getCookie(request, "XSRF-TOKEN");
                        String token = csrf.getToken();
                        if (cookie == null || token != null
                                && !token.equals(cookie.getValue())) {
                            cookie = new Cookie("XSRF-TOKEN", token);
                            cookie.setPath("/");
                            response.addCookie(cookie);
                        }
                    }
                    filterChain.doFilter(request, response);
                }
            };
        }
    }
}
```

### X-XSRF-TOKENヘッダーを受け取る

CSRFのTokenをヘッダー名、X-XSRF-TOKENで受け取れるようなCsrfTokenRepositoryを用意します。(csrfTokenRepository())
それをSpring Securityに設定します。(http.csrf().csrfTokenRepository())

```java:src/main/java/com/example/Ssp37Application.java
@SpringBootApplication
@Controller
public class Ssp37Application {
    // Omit
    @Configuration
    @Order(SecurityProperties.ACCESS_OVERRIDE_ORDER)
    protected static class SecurityConfiguration extends WebSecurityConfigurerAdapter {
        @Override
        protected void configure(HttpSecurity http) throws Exception {
            http
                .httpBasic()
            .and()
                .authorizeRequests()
                    .antMatchers("/webjars/**", "/js/**", "/templates/**", "/index.html", "/").permitAll()
                    .anyRequest().authenticated()
            .and()
                // Add
                .addFilterAfter(csrfHeaderFilter(), CsrfFilter.class)
                // Add
                .csrf()
                    .csrfTokenRepository(csrfTokenRepository())
            ;
        }

        // Omit

        // Add
        private CsrfTokenRepository csrfTokenRepository() {
            HttpSessionCsrfTokenRepository repository = new HttpSessionCsrfTokenRepository();
            repository.setHeaderName("X-XSRF-TOKEN");
            return repository;
        }
    }
}
```

### 再び動作確認する

403 (Forbidden)にならずlogoutできるはずなので試してみましょう。
Webアプリケーションを再起動して、 http://localhost:8080/index.html を開きます。
Basic認証のときと同じように、ユーザー名にuser、パスワードに起動時ログに出力されるパスワードを入れ、ログインします。
helloに遷移します。
次にlogoutをクリックします。
すると今度はGET http://localhost:8080/login?logout 401 (Unauthorized)になります。
これはSpring Securityが既定で、logout後に表示するページへRedirectionをしているからです。
ちょっと気持ち悪いですが、Unauthorizedになっているので、logoutによって認証が切れているようです。

## 参考

Sample Repository
https://github.com/quwahara/SSP37/tree/510-security