Spring Securityを使ったログイン機能ってハマりますよね。。
公式tutorial読んだり、さんざんググってもなんとなくわからない・・・
全ての仕様を把握したわけではないですが、とりあえずこんなんで動きましたという感じで読んでいただければ。
今回のコードはこちらから
Github
参考資料
構成と仕様
サーバーはREST APIとしてデータを吐くだけ、画面遷移はクライアント側。
基本的にはサーバー側で認証を行い、クライアント側で認可処理を行います。
サーバー
・spring securityを使った認証処理を行う。
→DBに格納されているユーザ情報から認証する。
・REST APIサーバーとしてjsonデータをクライアントに渡す。
(APIへのアクセス認可を行う)
クライアント
使用ライブラリ ui-router,ngStorage
・ui-routerによる画面遷移や、ボタン表示制御などの認可処理を行う。
・認証情報はngStorageを使ってlocalStorageに格納する
DB(テーブル構成)
ロールベースアクセス制御に基づき、下記のテーブル構成とする。
ロールベースアクセスについては下記参照
業務システムにおけるロールベースアクセス制御
・Userテーブル・・・複数のRoleを保有。パスワードはハッシュ化して格納。
・Roleテーブル・・・複数のPermissionを保有するロールを格納。
・Permissionテーブル・・・登録権限や参照権限などが格納。
ポイントとなるクラス
サーバー:
・LoginUserDetails・・・Userエンティティをもとに作成されるログインユーザ情報
・LoginUserDetailsService・・・ログインユーザ情報を取得するサービスクラス
・LoginService・・・認証処理を行うサービスクラス。クライアントから渡されるメールアドレス&パスワードから対象となるユーザ情報を取得し、CSRFトークンをcookieに追加する。
・SecurityConfig・・・認証不要アドレス設定など、認証/認可設定を記述。
クライアント:
・run.js・・・ui-routerを使った画面遷移時に、ユーザの権限に応じた認可設定をする。
認証処理(クライアントサイド)
ui-routerを使用して画面遷移します。
index.html内にログイン画面(loginform.html)とログイン後ホーム画面(home.html)、管理者ページ(admin.html)を呼び出せるようui-viewタグを設定しておきます。
ログイン画面
<!-- ログインフォーム -->
<div class="l-login">
<form ng-submit="LoginCtrl.login(LoginCtrl.credentials)" class="form-group">
<!-- メールアドレス -->
<input type="email" class="form-control" placeholder="メールアドレス" name="mailaddress" ng-model="LoginCtrl.credentials.mailaddress">
<!-- パスワード -->
<input type="password" class="form-control" placeholder="パスワード" name="password" ng-model="LoginCtrl.credentials.password">
<button type="submit" class="l-login-btn btn btn-success">ログイン</button>
</form>
<!-- 認証失敗時エラーメッセージ -->
<div ng-show="LoginCtrl.error">
入力されたメールアドレス、パスワードに誤りがあります。
</div>
</div>
/**
* ログイン画面コントローラ
*
* @return
*/
(function(){
'use strict';
function LoginCtrl($scope,$http,$state,$localStorage){
var self = this;
/**
* ログインボタン押下処理
* @param {[object]} credentials
* @return
*/
self.login = function(credentials){
$http.post("/demo/api/login",credentials)
/** 認証成功時 */
.success(function(apiResult){
$localStorage.userinfo = apiResult; //localStorageにログイン情報を格納
$state.go('home'); //home画面に遷移
})
/** 認証失敗時 */
.error(function(apiResult) {
self.error = true; //エラー表示フラグ:true
});
}
}
angular.module(demoApp).controller('LoginController',['$scope','$http','$state','$localStorage',LoginCtrl]);
})();
ログイン処理先のエンドポイントに向けて、入力されたメールアドレスとパスワードを渡します。
認証成功時は認証結果をuserinfoとしてlocalStorageに格納します。
とりあえずメールアドレスとパスワードをサーバーに投げるところまで。
認証処理(サーバーサイド)
まずは認証処理を行うサービスクラスから
- 認証処理サービス
@Autowired
private AuthenticationManager authManager;
/** logger */
private static final Logger logger = LoggerFactory.getLogger(LoginService.class);
/**
* 認証処理
* @param loginInfo
* @return
*/
public AuthResult login(LoginInfoDTO loginInfo){
Authentication authentication = null;
AuthResult authResult = new AuthResult();
try {
//メールアドレスとパスワードによる照合を実施
Authentication request = new UsernamePasswordAuthenticationToken(loginInfo.getMailaddress(), loginInfo.getPassword());
//対象ユーザが存在するかチェックし認証を行う
authentication = authManager.authenticate(request);
//認証OKの場合は、認証結果をcontextholderに設定
SecurityContextHolder.getContext().setAuthentication(authentication);
//認証済みユーザ情報を格納
LoginUserDetails principal = (LoginUserDetails)authentication.getPrincipal();
//クライアントへの返却データを設定
authResult.setUserName(principal.getUsername());
authResult.setPermissions(principal.getPermissions());
authResult.setRoles(principal.getAuthorities().stream().map(authority -> authority.getAuthority()).collect(Collectors.toList()));
}
//認証失敗の場合
catch (Exception e) {
logger.error(e.toString());
}
return authResult;
}
このloginメソッドに渡しているLoginInfoDtoが、クライアント側で入力されたメールアドレスとパスワードとなります。spring securityでの認証処理の流れとしては下記ページがわかりやすかったです。
spring Security 3.1 リファレンス - テクニカル・サマリ (1)
ユーザ名とパスワードが得られ、それらがUsernamePasswordAuthenticationToken(先に見たAuthenticationインターフェースの実装の1つです)のインスタンスのなかに格納されます。
トークンは検証のためAuthenticationManagerインスタンスに渡されます。
認証に成功するとAuthenticationManagerは十全な情報を設定された状態のAuthenticationインスタンスを返します。
このAuthenticationインスタンスを引数にしてSecurityContextHolder.getContext().setAuthentication(...)というメソッド・コールを行うことによってセキュリティ・コンテキストが確立されます。
これ以降、ユーザは“認証済みである”と見なされるようになります。
ユーザ名&パスワードのように2点認証の場合は、UsernamePasswordAuthenticaionTokenメソッドで良いでしょう。これがユーザ名&パスワード&社員番号などのような3点認証の場合はカスタマイズが必要です。
- ログインユーザ情報の作成
今回はログインユーザ情報として下記のような設定にしました。このログインユーザ情報を取得するのがLoginUserDetailsServiceクラスになります。
この2つに関してはおなじみの書籍が参考になります。
はじめてのspringboot
@Data
public class LoginUserDetails extends org.springframework.security.core.userdetails.User{
private static final long serialVersionUID = 1L;
/** 認証ユーザ名*/
private String name;
/** パスワード*/
private String password;
/** 権限リスト*/
private List<String> permissions;
/** ロールリスト*/
private Collection<GrantedAuthority> authorities;
/**
* コンストラクタ
* @param user
*/
public LoginUserDetails(User user){
//Userエンティティから、認証ユーザのユーザ名、パスワード、権限リスト、ロールリストを設定
super(user.getName(), user.getEncodedPassword(), new ArrayList<GrantedAuthority>());
name = user.getName();
password = user.getEncodedPassword();
//User -> list<Role> -> list<Permission> -> list<String>
permissions = user.getRoles()
.stream()
.flatMap(role -> role.getPermissions().stream()
.map(permission -> permission.getName()))
.collect(Collectors.toList());
//User -> list<Role> -> list<GrantedAuthority>
authorities = user.getRoles()
.stream()
.map(role -> new SimpleGrantedAuthority(role.getName()))
.collect(Collectors.toList());
}
}
@Service
public class LoginUserDetailsService implements UserDetailsService{
@Autowired
UserRepository repository;
@Override
public UserDetails loadUserByUsername(String mailaddress) throws UsernameNotFoundException{
//DBに登録されているメールアドレスから認証対象ユーザを取得
User user = repository.findOne(mailaddress);
if(user == null){
throw new UsernameNotFoundException("対象データがありません");
}
//取得したUserエンティティをもとにLoginUserDetailsを作成する
return new LoginUserDetails(user);
}
}
1.LoginUserDetailsServiceでメールアドレスからUserエンティティを取得
2.取得したUserエンティティを元に、ログインユーザ情報(LoginUserDetails)を作成
という流れです。
このログインユーザ情報からクライアントにユーザ名、権限リスト、ロールリストを返却するようにしています。
- 認証エンドポイントRestController
上記のLoginServiceを呼び出すRestController内容は次のとおり。
@RestController
public class LoginRestController {
@Autowired
private LoginService loginService;
/**
* ログイン処理
* @param loginInfo
* @param request
* @param response
* @return
*/
@RequestMapping(value="/api/login",method=RequestMethod.POST)
public ResponseEntity<AuthResult> login(@RequestBody LoginInfoDTO loginInfo,HttpServletRequest request,HttpServletResponse response){
//認証処理を実行
AuthResult authResult = loginService.login(loginInfo);
//認証OKの場合はcsrfトークンをクッキーにセット
if(authResult.getUserName() != null){
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<>(authResult,null,HttpStatus.OK);
}
//認証失敗時は401エラーを返却
else{
return new ResponseEntity<>(authResult,null,HttpStatus.UNAUTHORIZED);
}
}
}
Angularを使った場合はCSRF対策が必要になります。ここの処理に関しては下記記事から使わせていただきました。
AngularJSでSpring Security
- SecurityConfig
認証の最後にconfigクラスを作成します。
こちらも上記記事が大変参考になります。
@Configuration
@EnableWebMvcSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter{
@Override
protected void configure(HttpSecurity http) throws Exception {
http.authorizeRequests()
//認証チェック不要パスを設定
.antMatchers(
"/index",
"/api/login",
"/webjars/**",
"/app/**/*.js",
"/app/**/*.css",
"/app/views/**.html"
).permitAll()
//管理者ページへのURLは管理者権限を持っている場合のみ可能
.antMatchers("/admin","/api/v1/users").hasRole("ADMIN")
//上記パス意外へのアクセスは全て認証が必要
.anyRequest().authenticated()
//ログアウト設定
.and().logout()
//ログアウト実行apiを指定
.logoutRequestMatcher(new AntPathRequestMatcher("/api/logout"))
//CSRF対策
.and().csrf().csrfTokenRepository(csrfTokenRepository())
.and().addFilterAfter(csrfHeaderFilter(), CsrfFilter.class);
}
//セッションヘッダーにCSRFトークンを設定
private CsrfTokenRepository csrfTokenRepository(){
HttpSessionCsrfTokenRepository repository = new HttpSessionCsrfTokenRepository();
repository.setHeaderName("X-XSRF-TOKEN");
return repository;
}
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);
}
};
}
//認証処理設定
@Configuration
static class AuthenticationConfiguration extends GlobalAuthenticationConfigurerAdapter {
//認証ユーザ取得サービス
@Autowired
private LoginUserDetailsService userDetailService;
//ユーザパスワードをハッシュ化するEncoder設定
//パスワードハッシュに特化したアルゴリズムBCryptを指定
@Bean
PasswordEncoder passwordEncoder(){
return new BCryptPasswordEncoder();
}
//認証処理設定
public void init(AuthenticationManagerBuilder auth) throws Exception {
auth
//認証ユーザ取得サービスを指定
.userDetailsService(userDetailService)
//パスワード照合時のEncoderを指定
.passwordEncoder(passwordEncoder());
}
}
おそらくspring security使い方を調べると、formLoginを使った例をよく見るかと思いますが今回はCSRF対策をするために別途ログインエンドポイントを作成していますので、ログインに関する設定は記述しません。
index.htmlを読み込むときに必要なcssやjsは認証せずに呼び出せるように設定し、管理者ページやデータ取得時のAPIアドレスはROLE_ADMIN権限がなければアクセスできないよう設定しました。
AuthenticationConfigurationについては、書籍「はじめてのspringboot」のままですね。
パスワードはハッシュ化してDBに格納しておきます。
ざっくりとですが認証はこんなところでしょうか。
認可(クライアントサイド)
画面遷移やボタン表示制御などの認可については、クライアント側で実装していきます。
認証結果として取得した権限リストを元に認可設定をします。
- ボタン表示制御
<li ng-if="HomeCtrl.hasPermission('manageUser')"><a ng-click="HomeCtrl.goAdminPage()">管理ページ</a></li>
<li><p class="navbar-text">{{HomeCtrl.userName}}</p></li>
<li ><a ng-click="HomeCtrl.logout()"><i class="fa fa-power-off"/></a></li>
self.hasPermission = function(targetPermission){
return LoginUserService.hasPermission(targetPermission);
}
(function(){
'use strict';
function LoginUserService($localStorage){
var loginUserService = {
/**
* 指定された権限を持っているかチェック
* @param {[type]} targetPermission
* @return {Boolean}
*/
hasPermission: function(targetPermission){
for(var i=0; i<$localStorage.userinfo.permissions.length; i++){
if($localStorage.userinfo.permissions[i] === targetPermission){
return true;
}
}
return false;
}
}
return loginUserService;
}
angular.module(demoApp).factory('LoginUserService',['$localStorage',LoginUserService]);
})();
localStorageに格納してある認証情報に指定された権限があるかチェックしているだけですね。
localStorageへの格納方法はngStorageを使うのが良いと思います。
ngStorage
- 画面遷移時
続いて画面遷移時の認可については、runメソッドで定義しました。
ui-routerの$stateChangeStartというイベントを使って、stateが変化するタイミングで処理を発火させることができます。その中で指定された権限がlocalStorageにあるかチェックし、なければ遷移させないこととします。
function run($rootScope,$state,$localStorage,LoginUserService){
$rootScope.$on('$stateChangeStart',function(e,toState,toParams,fromState,fromParams){
// 管理者ページに遷移する場合
if(toState.name == 'admin'){
// admin権限がない場合は遷移させない
if(!LoginUserService.hasPermission('manageUser')){
e.preventDefault();
alert('権限がありません');
}
}
// ログインページに遷移する場合(ブラウザバック時の対応として)
if(toState.name == 'login'){
// ログイン状態の場合は、ログイン画面まで遷移させない
if($localStorage.userinfo){
$state.go('home'); //ホーム画面に遷移
}
}
// ホーム画面に遷移する場合(未ログイン時にホーム画面へのURLを直接入力された場合の対応として)
if(toState.name == 'home'){
if(!$localStorage.userinfo){
e.preventDefault();
alert('再度ログインしてください');
}
}
});
}
ここではログイン後、ブラウザバックでログイン画面まで戻ってしまう場合やホーム画面へのURLが直接入力された場合も権限をチェックするような処理としました。
(画面数が多い場合はこうして定義するのは煩雑ですね・・・)
参考記事はこちら
ui-router を使ってログイン必須のstateを作る
localStorageを使った認可の課題
localStorageの場合はブラウザ開発者ツールで値を変更できるため、不適切な認可が可能となってしまいます。
それが嫌であれば認可が必要なタイミングで、サーバーから認証情報を取得するような処理が必要かと思います。それはそれでサーバーとのアクセスが増え、ユーザ体験も損なわれる気がします。
なので今回はデータ取得APIへの認可をサーバー側で担保することで、画面遷移を不正にできてしまうのは容認することにしました。