Spring Bootな構成でSpring Securityを用いて一般的なログイン機構を実装する方法をまとめる。ここでは、フロントをjavascriptで、サーバー側はJSONを返すAPIとして実装するような構成を想定する。また、認証情報はRDBMS上のユーザー情報テーブルにて管理する。
Spring Securityの機能全般を有効にする
まずは、WebSecurityConfigurerAdapterを継承したクラスを作成し、@EnableWebSecurityアノテーションを付与してSpring Securityの機能を有効化する。
- @EnableWebSecurity
- public class MySecurityConfig extends WebSecurityConfigurerAdapter {
- // 略
- }
Spring Securityの設定
これらの設定は、WebSecurityConfigurerAdapterのconfigure(HttpSecurity)メソッドをオーバーライドして実装する。ここでのポイントは4つ。
- CSRF対策を無効化(今回は無関係なので無効化しているけど、別途CSRF対策は設定すべし)
- ログイン認証を行うパスを設定
- フォーム認証を有効化
- POST /loginでログイン処理がトリガーされる(カスタマイズすることも可能)
- @EnableWebSecurity
- public class MySecurityConfig extends WebSecurityConfigurerAdapter {
- /**
- * {@inheritDoc}
- */
- @Override
- protected void configure(HttpSecurity http) throws Exception {
- //
- // CSRF対策を無効化
- //
- http.csrf().disable();
- //
- // ログイン認証を行うパスを設定
- //
- http.authorizeRequests()
- // ログイン無しでアクセス許可するパス
- .antMatchers("/").permitAll()
- // その他はログインが必要
- .anyRequest().authenticated();
- //
- // フォーム認証を有効化
- //
- http.formLogin()
- }
- }
ログインが成功/失敗した場合の処理を実装
ログインが成功/失敗した場合に、それぞれJSON形式のレスポンスを返すように実装する。
まずは、ログイン成功のハンドラー(AuthenticationSuccessHandler)とログイン失敗のハンドラー(AuthenticationFailureHandler)を定義する。
- /**
- * ログイン成功時の動作を定義
- */
- private static final AuthenticationSuccessHandler LOGIN_SUCCESS = (req, res, auth) -> {
- // HTTP Statusは200
- res.setStatus(HttpServletResponse.SC_OK);
- // Content-Type: application/json
- res.setContentType(MediaType.APPLICATION_JSON_UTF8_VALUE);
- // Body
- res.getWriter().write(JsonUtil.encode(ImmutableMap.of("code", "login_success")));
- res.getWriter().flush();
- };
- /**
- * ログイン失敗時の動作を定義
- */
- private static final AuthenticationFailureHandler LOGIN_FAILED = (req, res, auth) -> {
- // HTTP Statusは401
- res.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
- // Content-Type: application/json
- res.setContentType(MediaType.APPLICATION_JSON_UTF8_VALUE);
- // Body
- res.getWriter().write(JsonUtil.encode(ImmutableMap.of("code", "login_failed")));
- res.getWriter().flush();
- };
ここで使っているImmutableMapは、Guavaライブラリのクラス。JsonUtilはObjectMapperを利用するための自作ユーティリティクラスとする。ObjectMapperは、別途DIコンテナに登録しておいたものを使うのが良さそうだが、ここでは主題ではないため適当に下記のように実装しておく。
- public class JsonUtil {
- public static String encode(Object src) {
- try {
- return new ObjectMapper().writeValueAsString(src);
- } catch (Exception e) {
- throw new IllegalArgumentException(e);
- }
- }
- }
最後に、前項で設定したフォーム認証の有効化の箇所へ、ハンドラーの紐付け設定を追記する。
- @EnableWebSecurity
- public class MySecurityConfig extends WebSecurityConfigurerAdapter {
- /**
- * {@inheritDoc}
- */
- @Override
- protected void configure(HttpSecurity http) throws Exception {
- // 中略
- //
- // フォーム認証を有効化
- //
- http.formLogin()
- //
- // ログイン成功のハンドラーを設定
- //
- .successHandler(LOGIN_SUCCESS)
- //
- // ログイン失敗のハンドラーを設定
- //
- .failureHandler(LOGIN_FAILED);
- }
- }
未ログインアクセスの制御
このままだと、ログインが必要なURL(例えば/test)にアクセスすると、/loginへのリダイレクトがレスポンスされる。Ajaxな通信を行う前提なので、ログインが必要な旨を示すJSONを返すように実装する。
まずは、認証エントリーポイントのハンドラー(AuthenticationEntryPoint)を定義する。
- /**
- * 認証エントリポイントの動作を定義
- */
- private static final AuthenticationEntryPoint LOGIN_REQUIRED = (req, res, auth) -> {
- // HTTP Statusは401
- res.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
- // Content-Type: application/json
- res.setContentType(MediaType.APPLICATION_JSON_UTF8_VALUE);
- // Body
- res.getWriter().write(JsonUtil.encode(ImmutableMap.of("code", "login_required")));
- res.getWriter().flush();
- };
次に、前項と同様にconfigure()メソッドの中で、ハンドラーの紐付け設定を追記する。
- @EnableWebSecurity
- public class MySecurityConfig extends WebSecurityConfigurerAdapter {
- /**
- * {@inheritDoc}
- */
- @Override
- protected void configure(HttpSecurity http) throws Exception {
- // 中略
- http.exceptionHandling()
- //
- // 要ログインページアクセスのハンドラーを設定
- //
- .authenticationEntryPoint(LOGIN_REQUIRED);
- }
- }
ユーザー情報を取得するサービスを定義
最後のステップとして、データベースからユーザー情報を取得して認証する部分を実装する。
まずは、データベースからユーザー情報を取得するサービスをUserDetailsServiceインターフェースの実装クラスとして定義する。
※実際には、JdbcTemplateではなくMyBatisやらDBFluteやらのお好きなO/Rマッパーを利用すると思う。
- /**
- * ユーザー情報を取得するサービス
- */
- @Service
- public class MyUserDetailsService implements UserDetailsService {
- private final JdbcTemplate jdbcTemplate;
- private static final String SQL
- = "select password from user where name = ?";
- private static final SimpleGrantedAuthority ROLE
- = new SimpleGrantedAuthority("ROLE_USER");
- /**
- * Constructor
- */
- public MyUserDetailsService(final JdbcTemplate jdbcTemplate) {
- this.jdbcTemplate = jdbcTemplate;
- }
- /**
- * {@inheritDoc}
- */
- @Override
- public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
- if (StringUtils.isEmpty(username)) {
- throw new UsernameNotFoundException("No username");
- }
- // データベースから該当ユーザー情報を取得
- final String password = jdbcTemplate.queryForObject(
- SQL, new Object[]{username}, String.class);
- if (StringUtils.isEmpty(password)) {
- throw new UsernameNotFoundException("No user");
- }
- // ユーザー情報を生成
- return new User(
- username,
- password,
- Collections.singleton(ROLE));
- }
- }
続いて、AuthenticationManagerBuilderへ、ユーザー情報取得サービスの紐付け設定を行う。
- @EnableWebSecurity
- public class MySecurityConfig extends WebSecurityConfigurerAdapter {
- private final MyUserDetailsService service;
- /**
- * Constructor
- */
- public MySecurityConfig(final MyUserDetailsService service) {
- this.service = service;
- }
- // 中略
- /**
- * {@inheritDoc}
- */
- @Autowired
- void configureAuthenticationManager(AuthenticationManagerBuilder auth) throws Exception {
- //
- // ユーザー情報取得サービスを紐付ける
- //
- auth.userDetailsService(service);
- }
- }
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"}