2017/04/30 追記
現バージョンでは、CookieCsrfTokenRepositoryを利用したほうが簡単に実装できるはずです。
https://docs.spring.io/spring-security/site/docs/current/reference/html/csrf.html#csrf-cookie
「はじめてのSpring Boot」が面白く、これはSpring覚えねば!と、いう気分になりWebアプリを実装中。
Spring Securityを使用すると認証周りの実装がとても簡単になるようなので使ってみるかー、SPAでもなんとかなるだろうと手を出したら割と大変でした。
もっと簡単に実現できそうではあるのですが。。
フロントは2.0の移行が大変そうなAngularJSを使用しました。
テンプレートがわかりやすいのでAngular1.x系は残しておいて欲しいなあ。
あと、開発が遅くてイマイチな評価のUI Bootstrapを使ってます。
#参考
-
はじめてのSpring Boot
読むといろいろ作りたくなります。本の薄さと裏腹に内容がとても濃いです。 -
Spring Security and Angular JS
Spring公式のAngularJSとSpring Securityの組み合わせ例。
ほぼこれでやりたいことできるのですが、自分の場合、ログイン画面をモーダルで表示したかったので、そこだけ適用できず。 -
Spring Security
Spring Security公式です。
#実装
##サーバサイド
SecurityConfigで、静的リソースとログインAPI以外は未認証の場合エラーとします。
POSTがデフォルトでCSRFチェック対象のため、最初のログインのURLをチェック対象外に設定しています。(requireCsrfProtectionMatcherの設定をしないと、ログインのPOSTが403)
チェック対象外ロジックはstackoverflowのここから拝借しました。
csrfTokenRepositoryはSpring Security and Angular JSのコードを使わせてもらっています。
Spring SecurityはデフォルトでCSRFチェックをしていて、AngularJSも$http($resourceでも)でX-XSRF-TOKENヘッダが自動的に付加されることを利用して、ここでCSRFのヘッダ名を指定しています。
AngularJSはCookie(名称:XSRF-TOKEN)からX-XSRF-TOKENトークンをHTTPヘッダにセットする(参考:$http)ので、Spring SecurityがAngularJSに合わせるとこうなるということですね。
@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Override
protected void configure(HttpSecurity http) throws Exception {
RequestMatcher csrfRequestMatcher = new RequestMatcher() {
// CSRF対象外URL:
private AntPathRequestMatcher[] requestMatchers = {
new AntPathRequestMatcher("/api/login")
};
@Override
public boolean matches(HttpServletRequest request) {
for (AntPathRequestMatcher rm : requestMatchers) {
if (rm.matches(request)) {
return false;
}
}
return true;
}
};
http.authorizeRequests()
.antMatchers(
"/images/**",
"/scripts/**",
"/styles/**",
"/views/**",
"/index.html",
"/api/login"
).permitAll()
.anyRequest().authenticated() //上記にマッチしなければ未認証の場合エラー
.and()
.csrf()
.requireCsrfProtectionMatcher(csrfRequestMatcher)
.csrfTokenRepository(this.csrfTokenRepository());
}
private CsrfTokenRepository csrfTokenRepository() {
HttpSessionCsrfTokenRepository repository = new HttpSessionCsrfTokenRepository();
repository.setHeaderName("X-XSRF-TOKEN");
return repository;
}
}
UserDetailsServiceを設定します。
@Configuration
public class GlobalAuthenticationConfig extends GlobalAuthenticationConfigurerAdapter {
@Override
public void init(AuthenticationManagerBuilder auth) throws Exception {
auth.userDetailsService(userDetailsService());
}
@Bean
UserDetailsService userDetailsService() {
return new FooAuthService();
}
}
ログインのエンドポイントになるRestControllerです。
サービスクラスで認証されたら組み込みのCSRFフィルターから取得したトークンをCookieにセットして、NGなら401を返却しています。
@RestController
@RequestMapping("/api/")
public class LoginRestController {
@Autowired
LoginService loginService;
@RequestMapping(value = "login", method = RequestMethod.POST)
ResponseEntity<PageDto> login(@RequestBody LoginDto loginDto, HttpServletRequest request, HttpServletResponse response) {
PageDto pageDto = loginService.login(loginDto);
if (pageDto.getHeaderDto().isAuth()) {
CsrfToken csrf = (CsrfToken) request.getAttribute(CsrfToken.class.getName());
if (csrf != null) {
Cookie cookie = WebUtils.getCookie(request, "XSRF-TOKEN");
String token = csrf.getToken();
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
if ((cookie == null || token != null && !token.equals(cookie.getValue()))
&& (authentication != null && authentication.isAuthenticated())) {
cookie = new Cookie("XSRF-TOKEN", token);
cookie.setPath("/"); //適切なスコープで
response.addCookie(cookie);
}
}
return new ResponseEntity<>(pageDto, null, HttpStatus.OK);
} else {
return new ResponseEntity<>(pageDto, null, HttpStatus.UNAUTHORIZED);
}
}
@RequestMapping(value = "logout", method = RequestMethod.POST)
void logout(HttpServletRequest request) {
try {
request.logout();
} catch (ServletException e) {
throw new RuntimeException(e);
}
}
}
認証処理は公式のロジックを拝借してます。
UserDetailsとUserDetailsServiceの実装クラスははじめてのSpring Bootがわかりやすいです。
@Service
public class LoginService {
@Autowired
protected Mapper mapper;
@Autowired
private AuthenticationManager authManager;
/**
* ログイン処理を行います.
* @return
*/
public PageDto login(LoginDto loginDto) {
PageDto pageDto = new PageDto();
LoginDto outLoginDto = mapper.map(loginDto, LoginDto.class);
pageDto.setLoginDto(outLoginDto);
HeaderDto headerDto = new HeaderDto();
pageDto.setHeaderDto(headerDto);
//Spring Security認証処理
Authentication authResult = null;
try {
Authentication request = new UsernamePasswordAuthenticationToken(
loginDto.getMailAddress(), loginDto.getPassword());
authResult = authManager.authenticate(request);
SecurityContextHolder.getContext().setAuthentication(authResult);
FooUserDetails principal = (FooUserDetails)authResult.getPrincipal();
headerDto.setNickName(principal.getUserInfo().getNickName());
headerDto.setAuth(true);
} catch(AuthenticationException e) {
outLoginDto.setMessage("メールアドレスかパスワードがまちがっています。");
headerDto.setAuth(false);
}
return pageDto;
}
}
##フロントエンド
UI Bootstrap使ってモーダルでログイン画面を表示しています。
index.htmlでログインのhtmlテンプレートを読み込みます。
<!-- 略 -->
<div class="header" ng-controller="HeaderCtrl">
<!-- 略 -->
<div class="collapse navbar-collapse" id="js-navbar-collapse">
<ul class="nav navbar-nav">
<li class="active"><a href="#/">Home</a></li>
<li ng-if="!headerDto.auth">
<input type="button" class="btn btn-info" value="ログイン" ng-click="loginDialog()"/>
</li>
<li ng-if="headerDto.auth">
<input type="button" class="btn btn-info" value="ログアウト" ng-click="logout()"/>
</li>
<li>
<div ng-if="headerDto.auth">{{headerDto.nickName}}さん</div>
</li>
</ul>
<div ng-include src="'./views/login.html'"></div>
</div>
<!-- 略 -->
</div>
<!-- 略 -->
モーダルで実行されるログインテンプレートです。
<div>
<script type="text/ng-template" id="login.tmpl.html">
<div class="modal-header">
<h4>ログイン</h4>
</div>
<div class="modal-body">
<span>{{loginDto.message}}</span>
<br/>
<label>メールアドレス</label>
<input type="text" class="form-control" ng-model="loginDto.mailAddress">
<br/>
<label>パスワード</label>
<input type="password" class="form-control" ng-model="loginDto.password">
<br/>
<input type="button" class="btn btn-info" value="ログイン" ng-click="login()"/>
<input type="button" class="btn btn-default" value="cancel" ng-click="cancel()"/>
</div>
</script>
</div>
LoginRestControllerで定義されたエンドポイントにアクセスするサービスです。
SIerがAngular+Javaで実装するんならRestController(Java)とそれにアクセスするサービス(JavaScript)がExcelで自動生成されたりするんでしょうか。
angular.module('fooApp').factory('loginHttp', ['$http',
function ($http) {
var login = {
login: function(param) {
return $http.post('/foo/api/login', param);
},
logout: function() {
return $http.post('/foo/api/logout');
}
}
return login;
}]);
ヘッダ部分のコントローラで「ログイン」ボタンが押下時にモーダルダイアログを起動させています。
angular.module('fooApp')
.controller('HeaderCtrl', ['$scope', '$modal', 'loginHttp',
function ($scope, $modal, loginHttp) {
$scope.loginDialog = function () {
var modalInstance = $modal.open({
size: 'sm',
templateUrl: 'login.tmpl.html',
controller: 'LoginCtrl'
});
modalInstance.result.then(function (data){
$scope.headerDto = data;
});
};
$scope.logout = function () {
loginHttp.logout();
$scope.headerDto = {};
}
}]);
ログイン画面のコントローラでログインサービスを実行します。
認証エラーの場合は401が返ってくるのでerror()に入ります。
angular.module('fooApp')
.controller('LoginCtrl', ['$scope', 'loginHttp', '$modalInstance',
function ($scope, loginHttp, $modalInstance) {
$scope.login = function () {
loginHttp.login({
mailAddress: $scope.loginDto.mailAddress,
password: $scope.loginDto.password
})
.success(
function (data) {
$scope.loginDto = data.loginDto;
$modalInstance.close(data.headerDto);
}
)
.error(
function (data) {
$scope.loginDto = data.loginDto;
}
)
}
$scope.cancel = function () {
$modalInstance.close();
}
}]);
#まとめ
モーダルのログイン画面でSpring Securityを実装するのは、ちょっと面倒でした。
もっと簡潔に実現できる方法があったら知りたいなあ。。
でも、Spring Securityで実現できるセキュリティ対策をフルスクラッチで作成することと比べたら、はるかに楽かなと思います。
Spring BootでSpring盛り上がっている感じなので、Spring関連の日本語資料いっぱいでるといいですね!