LoginSignup
7
7

More than 5 years have passed since last update.

Spring SecurityをAngularJSに組合せてみる

Last updated at Posted at 2016-04-16

はじめに

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を使ってみる

環境

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を追記します。

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

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

起動時ログのパスワード例
スクリーンショット 2016-04-15 10.26.15.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下にある静的リソースへのアクセスを許可しました。それ以外へのアクセスは保護されています。

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を追加します。

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にアクセスして、ログイン情報を破棄します。

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へ遷移するようにしています。

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>タグが有効になり、エラーメッセージが表示されます。

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を追加しました。

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を追加しました。

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())

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())

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

7
7
2

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
7
7