2017年9月14日木曜日

Spring BootとSpring Securityでログインを実装する方法

Spring Bootな構成でSpring Securityを用いて一般的なログイン機構を実装する方法をまとめる。ここでは、フロントをjavascriptで、サーバー側はJSONを返すAPIとして実装するような構成を想定する。また、認証情報はRDBMS上のユーザー情報テーブルにて管理する。

Spring Securityの機能全般を有効にする

まずは、WebSecurityConfigurerAdapterを継承したクラスを作成し、@EnableWebSecurityアノテーションを付与してSpring Securityの機能を有効化する。

  1.       
  2. @EnableWebSecurity  
  3. public class MySecurityConfig extends WebSecurityConfigurerAdapter {  
  4.     // 略  
  5. }  

Spring Securityの設定

これらの設定は、WebSecurityConfigurerAdapterのconfigure(HttpSecurity)メソッドをオーバーライドして実装する。ここでのポイントは4つ。

  • CSRF対策を無効化(今回は無関係なので無効化しているけど、別途CSRF対策は設定すべし)
  • ログイン認証を行うパスを設定
  • フォーム認証を有効化
  • POST /loginでログイン処理がトリガーされる(カスタマイズすることも可能)

  1. @EnableWebSecurity  
  2. public class MySecurityConfig extends WebSecurityConfigurerAdapter {  
  3.     /** 
  4.      * {@inheritDoc} 
  5.      */  
  6.     @Override  
  7.     protected void configure(HttpSecurity http) throws Exception {  
  8.         //  
  9.         // CSRF対策を無効化  
  10.         //  
  11.         http.csrf().disable();  
  12.   
  13.         //  
  14.         // ログイン認証を行うパスを設定  
  15.         //  
  16.         http.authorizeRequests()  
  17.             // ログイン無しでアクセス許可するパス  
  18.             .antMatchers("/").permitAll()  
  19.             // その他はログインが必要  
  20.             .anyRequest().authenticated();  
  21.   
  22.         //  
  23.         // フォーム認証を有効化  
  24.         //  
  25.         http.formLogin()  
  26.     }  
  27. }  

ログインが成功/失敗した場合の処理を実装

ログインが成功/失敗した場合に、それぞれJSON形式のレスポンスを返すように実装する。

まずは、ログイン成功のハンドラー(AuthenticationSuccessHandler)とログイン失敗のハンドラー(AuthenticationFailureHandler)を定義する。

  1. /** 
  2.  * ログイン成功時の動作を定義 
  3.  */  
  4. private static final AuthenticationSuccessHandler LOGIN_SUCCESS = (req, res, auth) -> {  
  5.     // HTTP Statusは200  
  6.     res.setStatus(HttpServletResponse.SC_OK);  
  7.   
  8.     // Content-Type: application/json  
  9.     res.setContentType(MediaType.APPLICATION_JSON_UTF8_VALUE);  
  10.   
  11.     // Body  
  12.     res.getWriter().write(JsonUtil.encode(ImmutableMap.of("code""login_success")));  
  13.     res.getWriter().flush();  
  14. };  
  15.   
  16. /** 
  17.  * ログイン失敗時の動作を定義 
  18.  */  
  19. private static final AuthenticationFailureHandler LOGIN_FAILED = (req, res, auth) -> {  
  20.     // HTTP Statusは401  
  21.     res.setStatus(HttpServletResponse.SC_UNAUTHORIZED);  
  22.   
  23.     // Content-Type: application/json  
  24.     res.setContentType(MediaType.APPLICATION_JSON_UTF8_VALUE);  
  25.   
  26.     // Body  
  27.     res.getWriter().write(JsonUtil.encode(ImmutableMap.of("code""login_failed")));  
  28.     res.getWriter().flush();  
  29. };  

ここで使っているImmutableMapは、Guavaライブラリのクラス。JsonUtilはObjectMapperを利用するための自作ユーティリティクラスとする。ObjectMapperは、別途DIコンテナに登録しておいたものを使うのが良さそうだが、ここでは主題ではないため適当に下記のように実装しておく。

  1. public class JsonUtil {  
  2.     public static String encode(Object src) {  
  3.         try {  
  4.             return new ObjectMapper().writeValueAsString(src);  
  5.         } catch (Exception e) {  
  6.             throw new IllegalArgumentException(e);  
  7.         }  
  8.     }  
  9. }  

最後に、前項で設定したフォーム認証の有効化の箇所へ、ハンドラーの紐付け設定を追記する。

  1. @EnableWebSecurity  
  2. public class MySecurityConfig extends WebSecurityConfigurerAdapter {  
  3.     /** 
  4.      * {@inheritDoc} 
  5.      */  
  6.     @Override  
  7.     protected void configure(HttpSecurity http) throws Exception {  
  8.   
  9.         // 中略  
  10.   
  11.         //  
  12.         // フォーム認証を有効化  
  13.         //  
  14.         http.formLogin()  
  15.             //  
  16.             // ログイン成功のハンドラーを設定  
  17.             //  
  18.             .successHandler(LOGIN_SUCCESS)  
  19.             //  
  20.             // ログイン失敗のハンドラーを設定  
  21.             //  
  22.             .failureHandler(LOGIN_FAILED);  
  23.     }  
  24. }  

未ログインアクセスの制御

このままだと、ログインが必要なURL(例えば/test)にアクセスすると、/loginへのリダイレクトがレスポンスされる。Ajaxな通信を行う前提なので、ログインが必要な旨を示すJSONを返すように実装する。

まずは、認証エントリーポイントのハンドラー(AuthenticationEntryPoint)を定義する。

  1. /** 
  2.  * 認証エントリポイントの動作を定義 
  3.  */  
  4. private static final AuthenticationEntryPoint LOGIN_REQUIRED = (req, res, auth) -> {  
  5.     // HTTP Statusは401  
  6.     res.setStatus(HttpServletResponse.SC_UNAUTHORIZED);  
  7.       
  8.     // Content-Type: application/json  
  9.     res.setContentType(MediaType.APPLICATION_JSON_UTF8_VALUE);  
  10.   
  11.     // Body  
  12.     res.getWriter().write(JsonUtil.encode(ImmutableMap.of("code""login_required")));  
  13.     res.getWriter().flush();  
  14. };  

次に、前項と同様にconfigure()メソッドの中で、ハンドラーの紐付け設定を追記する。

  1. @EnableWebSecurity  
  2. public class MySecurityConfig extends WebSecurityConfigurerAdapter {  
  3.     /** 
  4.      * {@inheritDoc} 
  5.      */  
  6.     @Override  
  7.     protected void configure(HttpSecurity http) throws Exception {  
  8.   
  9.         // 中略  
  10.   
  11.         http.exceptionHandling()  
  12.             //  
  13.             // 要ログインページアクセスのハンドラーを設定  
  14.             //  
  15.             .authenticationEntryPoint(LOGIN_REQUIRED);  
  16.     }  
  17. }  

ユーザー情報を取得するサービスを定義

最後のステップとして、データベースからユーザー情報を取得して認証する部分を実装する。

まずは、データベースからユーザー情報を取得するサービスをUserDetailsServiceインターフェースの実装クラスとして定義する。

※実際には、JdbcTemplateではなくMyBatisやらDBFluteやらのお好きなO/Rマッパーを利用すると思う。

  1. /** 
  2.  * ユーザー情報を取得するサービス 
  3.  */  
  4. @Service  
  5. public class MyUserDetailsService implements UserDetailsService {  
  6.     private final JdbcTemplate jdbcTemplate;  
  7.   
  8.     private static final String SQL  
  9.         = "select password from user where name = ?";  
  10.   
  11.     private static final SimpleGrantedAuthority ROLE  
  12.         = new SimpleGrantedAuthority("ROLE_USER");  
  13.       
  14.     /** 
  15.      * Constructor 
  16.      */  
  17.     public MyUserDetailsService(final JdbcTemplate jdbcTemplate) {  
  18.         this.jdbcTemplate = jdbcTemplate;  
  19.     }  
  20.   
  21.     /** 
  22.      * {@inheritDoc} 
  23.      */  
  24.     @Override  
  25.     public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {  
  26.         if (StringUtils.isEmpty(username)) {  
  27.             throw new UsernameNotFoundException("No username");  
  28.         }  
  29.   
  30.         // データベースから該当ユーザー情報を取得  
  31.         final String password = jdbcTemplate.queryForObject(  
  32.                 SQL, new Object[]{username}, String.class);  
  33.           
  34.         if (StringUtils.isEmpty(password)) {  
  35.             throw new UsernameNotFoundException("No user");  
  36.         }  
  37.   
  38.         // ユーザー情報を生成  
  39.         return new User(  
  40.                 username,  
  41.                 password,  
  42.                 Collections.singleton(ROLE));  
  43.     }  
  44. }  

続いて、AuthenticationManagerBuilderへ、ユーザー情報取得サービスの紐付け設定を行う。

  1. @EnableWebSecurity  
  2. public class MySecurityConfig extends WebSecurityConfigurerAdapter {  
  3.     private final MyUserDetailsService service;  
  4.   
  5.     /** 
  6.      * Constructor 
  7.      */  
  8.     public MySecurityConfig(final MyUserDetailsService service) {  
  9.         this.service = service;  
  10.     }  
  11.   
  12.     // 中略  
  13.   
  14.     /** 
  15.      * {@inheritDoc} 
  16.      */  
  17.     @Autowired  
  18.     void configureAuthenticationManager(AuthenticationManagerBuilder auth) throws Exception {  
  19.         //  
  20.         // ユーザー情報取得サービスを紐付ける  
  21.         //  
  22.         auth.userDetailsService(service);  
  23.     }  
  24. }  

GET /testの動作

要ログインURLへのアクセスは、ログインが必要な旨を示すJSONレスポンスが帰ってくる。

Cache-Control: no-cache, no-store, max-age=0, must-revalidate
Content-Type: application/json;charset=UTF-8
Date: Thu, 14 Sep 2017 14:24:14 GMT
Expires: 0
Pragma: no-cache
Set-Cookie: JSESSIONID=8A504C961F44779D571088EBFE5E7BA3; Path=/; HttpOnly
Transfer-Encoding: chunked
X-Content-Type-Options: nosniff
X-Frame-Options: DENY
X-Xss-Protection: 1; mode=block

{code: "login_required"}

POST /loginの動作(ユーザー名やパスワードが正しくない)

所謂、ログインが失敗するパターン。

Cache-Control: no-cache, no-store, max-age=0, must-revalidate
Content-Type: application/json;charset=UTF-8
Date: Thu, 14 Sep 2017 14:29:08 GMT
Expires: 0
Pragma: no-cache
Transfer-Encoding: chunked
X-Content-Type-Options: nosniff
X-Frame-Options: DENY
X-Xss-Protection: 1; mode=block

{code: "login_failed"}

POST /loginの動作(ユーザー名とパスワードが正しい)

ログインが成功するパターン。さらっと流したが、ログイン処理のデフォルト実装は、application/x-www-form-urlencodedなリクエストで、ユーザー名をusername、パスワードをpasswordという名前のパラメータとして送信する必要がある。

Cache-Control: no-cache, no-store, max-age=0, must-revalidate
Content-Type: application/json;charset=UTF-8
Date: Thu, 14 Sep 2017 14:35:07 GMT
Expires: 0
Pragma: no-cache
Set-Cookie: JSESSIONID=DA1076E9F2247B35BF5718CC75B9979B; Path=/; HttpOnly
Transfer-Encoding: chunked
X-Content-Type-Options: nosniff
X-Frame-Options: DENY
X-Xss-Protection: 1; mode=block

{code: "login_success"}

0 件のコメント:

コメントを投稿