コラム

Vue.js、Spring Bootシステムでの認証方法

今回はクライアント側をVue.js、サーバ側をSpringBootを使用した場合のログインでの認証、各アクセスに対しての認証方法の例を書いていきたいと思います。

認証の流れ

SpringBoog側

作成クラス一覧

以下、今回作成するクラス一覧になります。
DBアクセスは今回はR2DBCを使用していますが、認証に関しては効果は発揮できないので違う方法でも良いと思います。

作成クラス説明
AuthenticationFilterUsernamePasswordAuthenticationFilterクラスを継承するFilterクラス
ログイン時に呼ばれれ、認証依頼を掛ける
認証成功時のトークン作成や、ログインのパス指定などをする
CheckTokenFilterOncePerRequestFilterクラスを継承するFilterクラス
ログイン後にアクセスされた際のトークンチェックを行う
UserDetailsServiceImplUserDetailsServiceインターフェイスを実装するクラス
AuthenticationFilterから呼ばれ、ユーザ認証を行う
AccountRepositoryDBのAccountテーブルへクエリーを掛けるクラス
今回はユーザIDを指定して取得するメソッドと、全てのユーザ情報を取得するメソッドを用意する
AccountDBのAccountテーブルに対応するエンティティクラス
SecurityConfigWebSecurityConfigurerAdapterクラスを継承するConfigurationクラス
ここでパスに対する上記のFilterを実行するか、CSRFの設定等を行う
AccountControllerログイン後の認証を確認する為、今回はAccountControllerというユーザ情報取得のRestコントローラを作成しました。

実装

・AuthenticationFilter

//UsernamePasswordAuthenticationFilterを継承
public class AuthenticationFilter extends UsernamePasswordAuthenticationFilter{

	private AuthenticationManager authenticationManager;

	public AuthenticationFilter(AuthenticationManager authenticationManager) {

	    // AuthenticationManagerの設定
	    this.authenticationManager = authenticationManager;

	    // ログインパスを設定
	    setRequiresAuthenticationRequestMatcher(new AntPathRequestMatcher("/api/login","POST"));


	    // UserDetailsServiceImplでの認証成功時に呼ばれる
      // tokenを発行してレスポンスにセットする
	    this.setAuthenticationSuccessHandler((req,res,ex) -> {
	        // トークンの作成
	        String token = JWT.create()
	                .withIssuer("distant-view") //発行者
	                .withIssuedAt( new Date() )  //日付
	                .withClaim("username", ex.getName()) //keyに対してvalueの設定。様々な値を保持できる
	                .withClaim("role", ex.getAuthorities().iterator().next().toString())
	                .sign(Algorithm.HMAC256("secret")); // 利用アルゴリズムを指定してJWTを新規作成
	        System.out.println( token );
          
          // tokeをX-AUTH-TOKENにセットする
          // 認証情報はX-AUTO-TOKENに設定する事が良いみたいです。
          // 今回のシステムに関しては違う名前でも機能します。
	        res.setHeader("X-AUTH-TOKEN", token);
	        res.setStatus(200); //ステータスを正常に設定
 );
          //ログイン成功という結果とUserDetailsServiceImplで取得した権限情報をクライアントに返す。
	        res.getWriter().write("Success@" + SecurityContextHolder.getContext().getAuthentication().getAuthorities().iterator().next() );
	    });

	    // ログイン失敗時
	    this.setAuthenticationFailureHandler((req,res,ex) -> {
          //権限エラーを返す
	        res.sendError(HttpServletResponse.SC_UNAUTHORIZED);
	    });
	}

  //このメソッドでリクエストからログイン情報の取得、認証依頼を掛けます。
	@Override
	public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException {
	    try {
	        ServletInputStream stream = request.getInputStream();
	        // リクエストのjsonの値をUserFormにマッピングします。
	        UserForm form = new ObjectMapper().readValue(request.getInputStream(), UserForm.class);

	        // ここでデフォルトのProviderを利用し、ユーザ認証に関してはUserDetailsServiceの実装クラスのloadUserByUsernameを利用する
	        return this.authenticationManager.authenticate(
	                new UsernamePasswordAuthenticationToken(form.getUserid(), form.getPassword(), new ArrayList<>())
	        );
	    } catch (IOException e) {
	        throw new RuntimeException(e);
	    }
	}
}

・UserDetailsServiceImpl

UserDetailsServiceのサブクラスは2つあるとエラーになります。

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.security.crypto.factory.PasswordEncoderFactories;
import org.springframework.stereotype.Service;

import com.example.vuexmulti.entity.Account;
import com.example.vuexmulti.repository.AccountRepository;

import reactor.core.publisher.Mono;

@Service
public class UserDetailsServiceImpl implements UserDetailsService {

    //カウントDBアクセスへのRepositoryクラス
    @Autowired
    private AccountRepository accountRepository;

    //AuthenticationFilterが呼ばれたら、このメソッドが呼ばれる
    @Override
    public UserDetails loadUserByUsername(String userId) throws UsernameNotFoundException {
        try{
            //アカウントを取得する。R2DBCなのでMono型でかえって来る。
            //DBアクセスに関してはJPA等の通常のアクセスでも問題ありません。
            Mono<Account> account =  accountRepository.findByUserId(userId);
       
       //Mono<Account>からデータを取得し、Userを作成し返す。
       //Userを作成する際にアカウントからユーザID、パスワード、権限情報を設定しています。
            return accountRepository.findByUserId(userId).
            		map( a ->
            			new User( a.getUserid(),
            				PasswordEncoderFactories.createDelegatingPasswordEncoder().encode( a.getPassword() ),
            				Collections.singletonList( new SimpleGrantedAuthority( a.getRole()  ) )
            			)
            		).block();
        }catch (Exception e) {
        	e.printStackTrace();
            throw new UsernameNotFoundException("ユーザーが見つかりません");
        }
    }
}

・AccountRepository

import org.springframework.data.r2dbc.repository.Query;
import org.springframework.data.repository.reactive.ReactiveCrudRepository;
import com.example.vuexmulti.entity.Account;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;

//ユーザID用のメソッドだけ定義します。
//全ユーザ取得メソッドは用意されています。
public interface AccountRepository extends ReactiveCrudRepository<Account, Integer> {
    @Query("select * from account where userid = :userId")
    Mono<Account> findByUserId(String userId);
}

・Account

import org.springframework.data.annotation.Id;
import org.springframework.data.relational.core.mapping.Table;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;

@Getter
@Setter
@Data
@NoArgsConstructor
@AllArgsConstructor
@Table("account")
public class Account {
	@Id
	private Long id;
	private String userid;
	private String name;
	private String password;
	private String role;
	private String companyname;
	private String department;
}

・CheckTokenFilter

import java.io.IOException;
import java.util.Collections;
import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.filter.OncePerRequestFilter;
import com.auth0.jwt.JWT;
import com.auth0.jwt.algorithms.Algorithm;
import com.auth0.jwt.interfaces.DecodedJWT;

public class CheckTokenFilter extends OncePerRequestFilter  {
    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
        
        // クライアント側からX-AUTH-TOKENヘッダーでトークンを渡す実装をするので、
     // 此方側もX-AUTH-TOKENヘッダーからトークンを取得します。
        String token = request.getHeader("X-AUTH-TOKEN");

        // トークンが無い場合は何もせずセッションの状態を維持しない
        if(token == null ){
            filterChain.doFilter(request,response);
            return;
        }
        // Tokenの検証と認証を行う デコード出来ない場合は例外が発生
        DecodedJWT decodedJWT = JWT.require(Algorithm.HMAC256("secret")).build().verify(token);
        
        // usernameの取得
        String username = decodedJWT.getClaim("username").toString();
        // roleの取得
        String role = decodedJWT.getClaim("role").toString();


        // ログイン状態を維持すべく設定する。この処理が無いとアクセスエラーを返すようになります。
        SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken(username,null,Collections.singletonList(new SimpleGrantedAuthority( role ) ) ) );
        filterChain.doFilter(request,response);
    }
}

・SecurityConfig

import java.io.IOException;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;

import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.core.Authentication;
import org.springframework.security.web.authentication.logout.HttpStatusReturningLogoutSuccessHandler;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.CorsConfigurationSource;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;


@Configuration
@EnableWebSecurity //SpringSecurityを使うために必要
public class SecurityConfig extends WebSecurityConfigurerAdapter {
    @Override
    public void configure(HttpSecurity http) throws Exception {

      //CORS(クロスオリジンリクエスト)の許可設定、必要ないならしなくて良いです。
    	//http.cors().configurationSource(this.corsConfigurationSource());

        // 認証が必要なURL、必要ないURLを設定
        //ログイン関係のURL以外をauthenticatedで認証を必要にしています。
        http.authorizeRequests()
                .antMatchers("/").permitAll()
                .antMatchers("/api/login").permitAll()
                .antMatchers("/api/**").authenticated();

        //Filterを設定する
        //AuthenticationFilterを呼んだ後、CheckTokenFilterが呼ばれるように設定
        http.addFilter(new AuthenticationFilter(authenticationManager()));
        http.addFilterAfter(new CheckTokenFilter(), AuthenticationFilter.class);

        //csrf対策はトークンでやるので無効にする
        http.csrf().ignoringAntMatchers("/**");

        //ここからはログアウト用の設定
        //ログアウト用のハンドラーを用意する。(今回は特に何もしないので親クラスのメソッドを呼ぶだけ)
        HttpStatusReturningLogoutSuccessHandler handler = new HttpStatusReturningLogoutSuccessHandler() {
    		    @Override
    		    public void onLogoutSuccess(HttpServletRequest request, HttpServletResponse response,
    				    Authentication authentication) throws IOException {
    			      super.onLogoutSuccess(request, response, authentication);
    		    }
        };
        
        //ログアウトのURL、セッション削除、ハンドラーの設定をする。
        http.logout()
        .logoutUrl("/logout")
        .invalidateHttpSession(true)
        .logoutSuccessHandler( handler );

    }

    //CORSの設定情報を作成
    //Vue.jsが別サーバで動作してなければ、この記述は必要ないです。
    private CorsConfigurationSource corsConfigurationSource() {
        CorsConfiguration corsConfiguration = new CorsConfiguration();
        
        //全てのHTTPメソッドを許可
        corsConfiguration.addAllowedMethod(CorsConfiguration.ALL); 
      
        /全てのヘッダー情報の受信を許可
        corsConfiguration.addAllowedHeader(CorsConfiguration.ALL);

        //追加で許可するヘッダー情報
        corsConfiguration.addExposedHeader("X-AUTH-TOKEN");

        //CORSを許可するドメイン Vue.js側のサーバ側からのアクセスを可能にする
     //例はvue.js側のサーバがlocalhostのポート8081の別サーバで動作しているときは下記のような設定が必要です
        corsConfiguration.addAllowedOrigin("http://localhost:8081");

    //cookie情報の取得を許可するか
        corsConfiguration.setAllowCredentials(true); 

        UrlBasedCorsConfigurationSource corsSource = new UrlBasedCorsConfigurationSource();

     // CORSリクエストを許可するURLの形式
        corsSource.registerCorsConfiguration("/**", corsConfiguration);

        return corsSource;
    }
}

・AccountController

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.bind.annotation.CrossOrigin;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.bind.annotation.RestController;
import com.example.vuexmulti.entity.Account;
import com.example.vuexmulti.repository.AccountRepository;
import reactor.core.publisher.Flux;

@RestController
@RequestMapping("/api/account")
@CrossOrigin
public class AccountController {
    @Autowired
    private AccountRepository accountRepository;

	/**
	* ユーザリストを取得
	* @return
	*/
	@RequestMapping(value = "/list", method = RequestMethod.GET)
	public Flux<Account> getUserList() {

		return this.accountRepository.findAll();
	}
}

Vue.js側

作成モジュール一覧

以下が作成モジュール一覧になります。

モジュール名説明
store/index.jsVuexを使用してセッションに情報を格納するモジュール
ログイン情報、トークン、権限を管理する
Login.vueログイン画面
AccountRepository.jsのログイン処理を呼んでログイン機能を実装
AccountRepository.jsログイン処理や、ユーザ情報をサーバから取得するモジュール
AccountList.vueログイン後の認証を確認する為、今回はユーザ情報一覧画面を作成する
ログイン画面でログインが正常に通った場合に遷移し、AccountRepository.jsのユーザ
情報処理を呼んで、ユーザ一覧情報を表示する

実装

・store/index.js

import { createStore } from 'vuex'
import createPersistedState from 'vuex-persistedstate' //vuex-persistedstateをインストールする必要があります

export default createStore({
  state: {
    isLogin:false, //ログイン状態
    authToken:'',  //トークン
    role:'' //権限(今回はguestかadmin)
  },
  mutations: {
    setAuthToken(state, value){
      state.authToken = value
    },
    setIsLogin(state, value){
      state.isLogin = value
    },
    setRole(state, value){
      state.role = value
    }
  },
  actions: {
    setAuthToken: function(commit, value) {
      commit('setAuthToken', value)
    },
    setIsLogin: function(commit, value) {
      commit('setIsLogin', value)
    },
    setRole: function(commit, value) {
      commit('setRole', value)
    }
  },
  getters:{
    getAuthToken(state){
      return state.authToken
    },
    getIsLogin(state){
      return state.isLogin
    },
    getRole(state){
      return state.role
    }
  },
  
  plugins: [createPersistedState(
    { 
      storage: window.sessionStorage
    }
  )]
  
})

・Login.vue

<template>
	<div>
		<div class="inputItemDiv"><div style="color:red;">{{message}}</div></div>
		<div class="inputItemDiv"><div class="inputTitleDiv">ID</div><input class="input" type="text" v-model="loginForm.userid"></div>

		<div class="inputItemDiv"><div class="inputTitleDiv">パスワード</div><input class="input" type="password" v-model="loginForm.password"></div>
	
		<div class="inputItemDiv" style="text-align:right">
			<button  type="button" class="button" @click='cancel'>キャンセル</button>
			<button type="button" class="button" @click='login'>ログイン</button>
		</div>
	</div>

</template>

<script>
//アカウント情報をサーバと通信して取得するモジュール
import accountRepository from '../composables/AccountRepository'
//ルート情報を保持するモジュール
import { useRouter  } from 'vue-router'

import {ref} from 'vue';


export default ({
  name: 'Login',
  setup(){
    //画面のuserid、passwordとバインドさせる変数
    const loginForm = ref( {userid:'', password:''} )
   
    //ログイン結果のメッセージ表示用
    const message = ref(null)
    
    //AccountRepositoryモジュールからの変数、メソッドを宣言
    const { loginAccount } = accountRepository()


    //ルータ使用変数宣言
    const router = useRouter()
    
    //ログインメソッド、ログインボタン押下時に呼ばれる
    const login = () =>{
      //loginAccountメソッドを呼ぶ、loginAccountは非同期メソッドなので、
      //処理終了時に呼ばれるコールバックメソッドを渡す
      loginAccount( loginForm.value, callback )
    }
    
    //キャンセルメソッド、キャンセルボタン押下時に呼ばれる
    const cancel = () =>{
      loginForm.value.userid = ''
      loginForm.value.password = '' 
    }
    
    //データ取得時のコールバック( AccountRepositoryのloginAccount メソッドに渡す)
    const callback = ( result ) =>{
      //処理が成功しならユーザリスト画面に遷移
      if( result == "Success"){
        router.push("/accountList")
      }else{
         var data = result.response.data
        //ステータス401の場合は下記のエラーを出力し終了
        if( data.status == 401 ){
          message.value = "ユーザID又はパスワードが間違っています。"
        }else{
          //それ以外の場合は、エラーを出力し終了
          message.value = data.error
        }
      }
    }
    
    //template上で使用する変数を宣言
    return{
      loginForm,
      login,
      cancel,
      message
    }
  }

})
</script>

・AccountRepository.js

import store from '@/store/index';
import * as axios from 'axios'

export default function (){
  //ログインメソッド
  const loginAccount = async( loginForm, callback ) => {
    try{
      //Spring Bootにaxiosでログイン依頼
      var result = await requestServerPost ( "api/login", loginForm )
      var results = result .split('@')

      //ログイン成功の場合はトークン、ログイン情報、権限をvuexに格納
      if( results[0] == "Success"){
        store.commit("setIsLogin", true)
        store.commit("setRole", results[1])
        callback( "Success" )
        return
      }

      //コールバックに結果を返す
      callback( result )
    }catch(e){
      callback(e)
    }
  }

  //axiosのポストメソッドでSpring Boot側へリクエストを掛ける
  const requestServerPost = ( url, formData ) =>{
    return new Promise((resolve, reject) => {
      axios
        .post('http://localhost:8080/' + url, formData)
        .then(response => {
           if( response.headers['x-auth-token'] != null ){
             store.commit("setAuthToken", response.headers['x-auth-token'])
           }
           resolve(response.data)
         }).catch(error => {
            reject(error)
         })
    }).catch((e) => {
      throw e
    })
  }

  //ユーザ情報取得
  const getAccount = async( callback ) => {
    try{
      //Spring Bootにaxiosでユーザ取得依頼
      var token = store.getters.getAuthToken
      var accountList = await requestServerGetWidthToken ( "api/account/list", token)

      //コールバックに結果を返す
      callback( "success", accountList )
    }catch(e){
      callback(e)
    }
  }

  //axiosのGetメソッドでトークンをヘッダーにセットしSpring Boot側へリクエストを掛ける
  const requestServerGetWidthToken = ( url, token ) =>{
    return new Promise((resolve, reject) => {
      axios
        .get('http://localhost:8080/' + url
          ,{
              headers: {
                "X-AUTH-TOKEN" : token
              }
          } 
         )
        .then(response => {
           resolve(response.data)
         }).catch(error => {
            reject(error)
         })
    }).catch((e) => {
      throw e
    })
  }

  return{
    loginAccount,
    getAccount
  }
}	

・AccountList.vue

<template>

  <div>
		<div class="inputItemDiv"><div style="color:red;">{{message}}</div></div>
		<div class="table" style="max-height:300px;width:900px;">
			<table>
				<thead>
					<tr>
						<th>アカウントID</th>
						<th>名前</th>
						<th>会社名</th>
						<th>部署</th>
					</tr>
				</thead>
				<tbody>
					<tr  v-for="(data) in accountList" :key="data.id">
						<td>{{ data.userid }}</td>
						<td>{{ data.name }}</td>
						<td>{{ data.companyname }}</td>
						<td>{{ data.department }}</td>

					</tr>
				</tbody>
			</table>
		</div>
		
	</div>

</template>

<script>
import { ref } from 'vue'
import accountRepository from '../composables/AccountRepository'

export default ({
  name: 'AccountList',
  setup(){
    //ユーザ情報リスト
    const accountList = ref([])

    //ユーザ取得結果メッセージ
    const message = ref(null)

    //accountRepositoryモジュールからgetAccountメソッドを取得
    const { getAccount } = accountRepository()
    
    //ユーザ情報取得
    getAccount( callback )
    
    //データ取得時のコールバック( AccountRepositoryのgetAccountメソッドに渡す)
    const callback = ( result, data )=>{
      //取得成功の場合はaccountListに取得データを設定
      if( result == "success"){
        message.value = "データ取得完了"
        accountList.value = data
      }else{
        message.value = result
      }
    }
    
    return{
      accountList,
      message
    }  
  }
})
</script>

以上が私が使用している認証方法でした。
次回は権限による画面や、SpringBootへのアクセス制御をやっていきたいと思います。

今回と次回を合わせたソースは此方からダウンロードできます。

この記事をシェアする
  • Facebookアイコン
  • Twitterアイコン
  • LINEアイコン

お問い合わせ ITに関するお悩み不安が少しでもありましたら、
ぜひお気軽にお問い合わせください

お客様のお悩みや不安、課題などを丁寧に、そして誠実にお伺いいたします。

お問い合わせはこちら
お電話でのお問い合わせ 03-5820-1777(平日10:00〜18:00)
よくあるご質問