はじめに
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を追記します。
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認証ダイアログが表示されるはずですが、いかかでしょうか。
ユーザー名にuser、パスワードに起動時ログに出力されるパスワードを入れ、ログインします。
画面が表示されれば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下にある静的リソースへのアクセスを許可しました。それ以外へのアクセスは保護されています。
@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を追加します。
@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にアクセスして、ログイン情報を破棄します。
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へ遷移するようにしています。
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>タグが有効になり、エラーメッセージが表示されます。
<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 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を追加しました。
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())
@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())
@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