かんがるーさんの日記

最近自分が興味をもったものを調べた時の手順等を書いています。今は Spring Boot をいじっています。

Spring Boot で書籍の貸出状況確認・貸出申請する Web アプリケーションを作る ( 番外編 )( Spring Security でいろいろ試してみる )

概要

今回ログイン画面を作成する上で Spring Security について書籍や Web でいろいろ調べたので、忘れないうちに最初に書いていた機能以外にも試してみたいと思います。

  • 今回の手順で確認できるのは以下の内容です。
    • ログイン画面は表示せず URL のみで認証する ( パラメータで渡された値で認証してOKなら認証後の画面へ )
    • ログイン画面で remember-me Cookie が有効ならログイン画面を表示せず自動ログインする

参照したサイト・書籍

  1. Spring3入門

    Spring3入門 ――Javaフレームワーク・より良い設計とアーキテクチャ

    Spring3入門 ――Javaフレームワーク・より良い設計とアーキテクチャ

    • 「6.4.2 Controllerメソッドの引数」を参照しました。

目次

  1. ログイン画面は表示せず URL のみで認証する ( パラメータで渡された値で認証してOKなら認証後の画面へ )
    1. 1.0.x-make-urllogin ブランチの作成
    2. LoginController クラスの変更
    3. Bean を @Autowired アノテーションでインジェクションするとエディタで赤の下線が表示されるのを無効にする
    4. WebSecurityConfig クラスの変更
    5. LendingUserDetailsService の変更
    6. 動作確認
    7. commit、Push、Pull Request、マージ
  2. ログイン画面の URL にアクセス時に有効な remember-me Cookie があればログイン画面を表示せず自動ログインする
    1. 実装方針
    2. 1.0.x-autologin-rememeberme ブランチの作成
    3. WebSecurityConfig クラスの変更
    4. LoginController クラスの変更
    5. 動作確認
    6. commit、Push、Pull Request、マージ

手順

ログイン画面は表示せず URL のみで認証する

動作確認のために、ユーザ名を書いたパラメータだけを付けた URL を用意しておいて、その URL でアクセスすればパラメータに書いたユーザでログインした状態になるようにしたいことがあります。単にログイン画面を表示して ID、パスワードを入力するのが面倒だと思っているだけなのですが。。。

Spring Security でこれができるか試してみます。URL は /urllogin?user=[メールアドレス] にします。

1.0.x-make-urllogin ブランチの作成

  1. IntelliJ IDEA で 1.0.x-make-urllogin ブランチを作成します。

LoginController クラスの変更

LoginController クラスに /urllogin に対応するメソッドを追加します。

  1. src/main/java/ksbysample/webapp/lending/web の下の LoginController.javaリンク先のその1の内容 に変更します。

Bean を @Autowired アノテーションでインジェクションするとエディタで赤の下線が表示されるのを無効にする

@SuppressWarnings("SpringJavaAutowiringInspection") アノテーションを付加すると赤の下線は表示されなくなりますが、アノテーションを付けて回避することに意義が見いだせないのでチェック自体を無効にします。何のためのチェックか全く分からないんですよね。。。

  1. src/main/java/ksbysample/webapp/lending/config の下の WebSecurityConfig.javaリンク先のその1の内容 に変更します。

  2. userDetailsService フィールドに赤の下線が表示されますので、カーソルを移動した後 Alt+Enter を押します。コンテキストメニューが表示されたら「Inspection 'Autowiring for Bean Class' options」->「Disable inspection」を選択します。

    f:id:ksby:20150801152339p:plain

  3. userDetailsService フィールドに赤の下線が表示されなくなります。

    f:id:ksby:20150801152608p:plain

WebSecurityConfig クラスの変更

/urllogin へのアクセスを認証不要に設定します。

  1. src/main/java/ksbysample/webapp/lending/config の下の WebSecurityConfig.javaリンク先のその2の内容 に変更します。

LendingUserDetailsService の変更

  1. 今回の機能とは関係ありませんが、気になった点があったので修正します。src/main/java/ksbysample/webapp/lending/security の下の LendingUserDetailsService.javaリンク先の内容 に変更します。

動作確認

  1. Gradle projects View から bootRun タスクを実行して Tomcat を起動します。

  2. ブラウザを起動して http://localhost:8080/urllogin?user=tanaka.taro@sample.com にアクセスします。自動的にログインして「ログイン成功!」の画面が表示されます。

    f:id:ksby:20150802060155p:plain

  3. ブラウザから http://localhost:8080/logout にアクセスしてログアウトします。

  4. ブラウザから http://localhost:8080/urllogin?user=test にアクセスします。今度は存在しないメールアドレスなので、UsernameNotFoundException が発生してログイン画面が表示されす。

    f:id:ksby:20150802061129p:plain

    f:id:ksby:20150802061211p:plain

  5. Ctrl+F2 を押して Tomcat を停止します。

commit、Push、Pull Request、マージ

  1. commit、GitHub へ Push、1.0.x-make-urllogin -> 1.0.x へ Pull Request、1.0.x でマージ、1.0.x-make-urllogin ブランチを削除、をします。

ログイン画面の URL にアクセス時に有効な remember-me Cookie があればログイン画面を表示せず自動ログインする

実装方針

org.springframework.security.web.authentication.rememberme.RememberMeAuthenticationFilter の doFilter メソッドの中に remember-me Cookie がある時の認証処理があるので、ここから必要な処理を持ってくればよいはずです。その方向で実装してみます。

1.0.x-autologin-rememeberme ブランチの作成

  1. IntelliJ IDEA で 1.0.x-autologin-rememeberme ブランチを作成します。

WebSecurityConfig クラスの変更

  1. Remember Me 機能に使用する key の文字列を LoginController クラスから利用できるようにします。src/main/java/ksbysample/webapp/lending/config の下の WebSecurityConfig.javaリンク先のその3の内容 に変更します。

LoginController クラスの変更

  1. src/main/java/ksbysample/webapp/lending/web の下の LoginController.javaリンク先のその2の内容 に変更します。

動作確認

  1. Gradle projects View から bootRun タスクを実行して Tomcat を起動します。

  2. ブラウザを起動して http://localhost:8080/logout にアクセスし、remember-me Cookie が存在しても一旦削除します。

  3. ブラウザから http://localhost:8080/ にアクセスし、ログイン画面を表示します。ID に "tanaka.taro@sample.com"、Password に "taro" を入力して、「次回から自動的にログインする」をチェックして「ログイン」ボタンをクリックします。「ログイン成功!」の画面が表示されます。これで remember-me Cookie が生成されました。

    f:id:ksby:20150804010217p:plain

    f:id:ksby:20150804010335p:plain

  4. ブラウザを一旦終了させてから再度起動します。

  5. ブラウザから http://localhost:8080/ にアクセスします。有効な remember-me Cookie が存在するので、ログイン画面は表示されず「ログイン成功!」の画面が表示されます。

    f:id:ksby:20150804010806p:plain

  6. 再度 http://localhost:8080/logout にアクセスし、remember-me Cookie を削除します。

  7. ブラウザを一旦終了させてから再度起動します。

  8. ブラウザから http://localhost:8080/ にアクセスします。今度は remember-me Cookie が存在しないのでログイン画面が表示されます。

    f:id:ksby:20150804011149p:plain

  9. Ctrl+F2 を押して Tomcat を停止します。

commit、Push、Pull Request、マージ

  1. commit、GitHub へ Push、1.0.x-autologin-rememeberme -> 1.0.x へ Pull Request、1.0.x でマージ、1.0.x-autologin-rememeberme ブランチを削除、をします。

ソースコード

LoginController.java

■その1

package ksbysample.webapp.lending.web;

import ksbysample.webapp.lending.config.WebSecurityConfig;
import ksbysample.webapp.lending.dao.UserInfoDao;
import ksbysample.webapp.lending.dao.UserRoleDao;
import ksbysample.webapp.lending.entity.UserInfo;
import ksbysample.webapp.lending.entity.UserRole;
import ksbysample.webapp.lending.security.LendingUserDetails;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.MessageSource;
import org.springframework.context.i18n.LocaleContextHolder;
import org.springframework.security.authentication.AuthenticationDetailsSource;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.ResponseBody;

import javax.servlet.http.HttpServletRequest;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;

@Controller
@RequestMapping("/")
public class LoginController {

    @Autowired
    private UserInfoDao userInfoDao;

    @Autowired
    private UserRoleDao userRoleDao;

    @Autowired
    private MessageSource messageSource;

    @RequestMapping
    public String index() {
        return "login";
    }

    @RequestMapping("/encode")
    @ResponseBody
    public String encode(@RequestParam String password) {
        return new BCryptPasswordEncoder().encode(password);
    }

    @RequestMapping("/loginsuccess")
    public String loginsuccess() {
        return "loginsuccess";
    }

    @RequestMapping("/urllogin")
    public String urllogin(@RequestParam String user
            , HttpServletRequest request) {
        // user パラメータで指定されたメールアドレスのユーザが user_info テーブルに存在するかチェックする
        UserInfo userInfo = userInfoDao.selectByMailAddress(user);
        if (userInfo == null) {
            throw new UsernameNotFoundException(
                    messageSource.getMessage("UserInfoUserDetailsService.usernameNotFound"
                            , null, LocaleContextHolder.getLocale()));
        }

        // user_role テーブルから設定されている権限を読み込む
        Set<SimpleGrantedAuthority> authorities = new HashSet<>();
        List<UserRole> userRoleList = userRoleDao.selectByUserId(userInfo.getUserId());
        if (userRoleList != null) {
            authorities.addAll(
                    userRoleList.stream()
                            .map(userRole -> new SimpleGrantedAuthority(userRole.getRole()))
                            .collect(Collectors.toList()));
        }

        // UsernamePasswordAuthenticationToken を生成して SecurityContext にセットする
        LendingUserDetails lendingUserDetails = new LendingUserDetails(userInfo, authorities);
        UsernamePasswordAuthenticationToken token
                = new UsernamePasswordAuthenticationToken(lendingUserDetails, null, authorities);
        // 下の2行は org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter の
        // setDetails メソッドを見て実装しています
        AuthenticationDetailsSource<HttpServletRequest,?> authenticationDetailsSource = new WebAuthenticationDetailsSource();
        token.setDetails(authenticationDetailsSource.buildDetails(request));
        SecurityContextHolder.getContext().setAuthentication(token);

        return "redirect:" + WebSecurityConfig.DEFAULT_SUCCESS_URL;
    }

}

■その2

package ksbysample.webapp.lending.web;

import ksbysample.webapp.lending.config.WebSecurityConfig;
import ksbysample.webapp.lending.dao.UserInfoDao;
import ksbysample.webapp.lending.dao.UserRoleDao;
import ksbysample.webapp.lending.entity.UserInfo;
import ksbysample.webapp.lending.entity.UserRole;
import ksbysample.webapp.lending.security.LendingUserDetails;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.MessageSource;
import org.springframework.context.i18n.LocaleContextHolder;
import org.springframework.security.authentication.AuthenticationDetailsSource;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
import org.springframework.security.web.authentication.rememberme.TokenBasedRememberMeServices;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.ResponseBody;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;

@Controller
@RequestMapping("/")
public class LoginController {

    @Autowired
    private UserInfoDao userInfoDao;

    @Autowired
    private UserRoleDao userRoleDao;

    @Autowired
    private MessageSource messageSource;

    @Autowired
    private UserDetailsService userDetailsService;
    
    @RequestMapping
    public String index(HttpServletRequest request, HttpServletResponse response) {
        // 有効な remember-me Cookie が存在する場合にはログイン画面を表示させず自動ログインさせる
        TokenBasedRememberMeServices rememberMeServices
                = new TokenBasedRememberMeServices(WebSecurityConfig.REMEMBERME_KEY, userDetailsService);
        rememberMeServices.setCookieName("remember-me");
        Authentication rememberMeAuth = rememberMeServices.autoLogin(request, response);
        if (rememberMeAuth != null) {
            SecurityContextHolder.getContext().setAuthentication(rememberMeAuth);
            return "redirect:" + WebSecurityConfig.DEFAULT_SUCCESS_URL;
        }

        return "login";
    }

    @RequestMapping("/encode")
    @ResponseBody
    public String encode(@RequestParam String password) {
        return new BCryptPasswordEncoder().encode(password);
    }

    @RequestMapping("/loginsuccess")
    public String loginsuccess() {
        return "loginsuccess";
    }

    @RequestMapping("/urllogin")
    public String urllogin(@RequestParam String user
            , HttpServletRequest request) {
        // user パラメータで指定されたメールアドレスのユーザが user_info テーブルに存在するかチェックする
        UserInfo userInfo = userInfoDao.selectByMailAddress(user);
        if (userInfo == null) {
            throw new UsernameNotFoundException(
                    messageSource.getMessage("UserInfoUserDetailsService.usernameNotFound"
                            , null, LocaleContextHolder.getLocale()));
        }

        // user_role テーブルから設定されている権限を読み込む
        Set<SimpleGrantedAuthority> authorities = new HashSet<>();
        List<UserRole> userRoleList = userRoleDao.selectByUserId(userInfo.getUserId());
        if (userRoleList != null) {
            authorities.addAll(
                    userRoleList.stream()
                            .map(userRole -> new SimpleGrantedAuthority(userRole.getRole()))
                            .collect(Collectors.toList()));
        }

        // UsernamePasswordAuthenticationToken を生成して SecurityContext にセットする
        LendingUserDetails lendingUserDetails = new LendingUserDetails(userInfo, authorities);
        UsernamePasswordAuthenticationToken token
                = new UsernamePasswordAuthenticationToken(lendingUserDetails, null, authorities);
        // 下の2行は org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter の
        // setDetails メソッドを見て実装しています
        AuthenticationDetailsSource<HttpServletRequest,?> authenticationDetailsSource = new WebAuthenticationDetailsSource();
        token.setDetails(authenticationDetailsSource.buildDetails(request));
        SecurityContextHolder.getContext().setAuthentication(token);

        return "redirect:" + WebSecurityConfig.DEFAULT_SUCCESS_URL;
    }

}
  • private UserDetailsService userDetailsService; を追加します。
  • index メソッドの引数に HttpServletRequest request, HttpServletResponse response を追加します。
  • index メソッドに remember-me Cookie の有無を確認して自動ログインするための処理を追加します。

WebSecurityConfig.java

■その1

@Configuration
@Order(SecurityProperties.ACCESS_OVERRIDE_ORDER)
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {

    public static final String DEFAULT_SUCCESS_URL = "/loginsuccess";
    
    @Autowired
    private UserDetailsService userDetailsService;
  • userDetailsService に付加していた @SuppressWarnings("SpringJavaAutowiringInspection") アノテーションを削除します。

■その2

package ksbysample.webapp.lending.config;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.autoconfigure.security.SecurityProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.annotation.Order;
import org.springframework.security.authentication.AuthenticationProvider;
import org.springframework.security.authentication.dao.DaoAuthenticationProvider;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.web.util.matcher.AntPathRequestMatcher;

@Configuration
@Order(SecurityProperties.ACCESS_OVERRIDE_ORDER)
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {

    public static final String DEFAULT_SUCCESS_URL = "/loginsuccess";
    
    @Autowired
    private UserDetailsService userDetailsService;

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.authorizeRequests()
                // 認証の対象外にしたいURLがある場合には、以下のような記述を追加します
                // 複数URLがある場合はantMatchersメソッドにカンマ区切りで対象URLを複数列挙します
                // .antMatchers("/country/**").permitAll()
                .antMatchers("/fonts/**").permitAll()
                .antMatchers("/html/**").permitAll()
                .antMatchers("/encode").permitAll()
                .antMatchers("/urllogin").permitAll()
                .anyRequest().authenticated();
        http.formLogin()
                .loginPage("/")
                .loginProcessingUrl("/login")
                .defaultSuccessUrl(WebSecurityConfig.DEFAULT_SUCCESS_URL)
                .failureUrl("/")
                .usernameParameter("id")
                .passwordParameter("password")
                .permitAll()
                .and()
                .logout()
                .logoutRequestMatcher(new AntPathRequestMatcher("/logout"))
                .logoutSuccessUrl("/")
                .deleteCookies("JSESSIONID")
                .deleteCookies("remember-me")
                .invalidateHttpSession(true)
                .permitAll()
                .and()
                .rememberMe()
                .key("ksbysample-webapp-lending")
                .tokenValiditySeconds(60 * 60 * 24 * 30);
    }

    @Bean
    public AuthenticationProvider daoAuhthenticationProvider() {
        DaoAuthenticationProvider daoAuthenticationProvider = new DaoAuthenticationProvider();
        daoAuthenticationProvider.setUserDetailsService(userDetailsService);
        daoAuthenticationProvider.setHideUserNotFoundExceptions(false);
        daoAuthenticationProvider.setPasswordEncoder(new BCryptPasswordEncoder());
        return daoAuthenticationProvider;
    }

    @Autowired
    public void configAuthentication(AuthenticationManagerBuilder auth) throws Exception {
        auth.authenticationProvider(daoAuhthenticationProvider())
                .userDetailsService(userDetailsService);
    }

}
  • public static final String DEFAULT_SUCCESS_URL = "/loginsuccess"; を追加します。
  • configure メソッド内の以下の点を変更します。
    • .antMatchers("/urllogin").permitAll() を追加します。
    • .defaultSuccessUrl("/loginsuccess").defaultSuccessUrl(WebSecurityConfig.DEFAULT_SUCCESS_URL) へ変更します。

■その3

package ksbysample.webapp.lending.config;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.autoconfigure.security.SecurityProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.annotation.Order;
import org.springframework.security.authentication.AuthenticationProvider;
import org.springframework.security.authentication.dao.DaoAuthenticationProvider;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.web.util.matcher.AntPathRequestMatcher;

@Configuration
@Order(SecurityProperties.ACCESS_OVERRIDE_ORDER)
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {

    public static final String DEFAULT_SUCCESS_URL = "/loginsuccess";
    public static final String REMEMBERME_KEY = "ksbysample-webapp-lending";
    
    @Autowired
    private UserDetailsService userDetailsService;

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.authorizeRequests()
                // 認証の対象外にしたいURLがある場合には、以下のような記述を追加します
                // 複数URLがある場合はantMatchersメソッドにカンマ区切りで対象URLを複数列挙します
                // .antMatchers("/country/**").permitAll()
                .antMatchers("/fonts/**").permitAll()
                .antMatchers("/html/**").permitAll()
                .antMatchers("/encode").permitAll()
                .antMatchers("/urllogin").permitAll()
                .anyRequest().authenticated();
        http.formLogin()
                .loginPage("/")
                .loginProcessingUrl("/login")
                .defaultSuccessUrl(WebSecurityConfig.DEFAULT_SUCCESS_URL)
                .failureUrl("/")
                .usernameParameter("id")
                .passwordParameter("password")
                .permitAll()
                .and()
                .logout()
                .logoutRequestMatcher(new AntPathRequestMatcher("/logout"))
                .logoutSuccessUrl("/")
                .deleteCookies("JSESSIONID")
                .deleteCookies("remember-me")
                .invalidateHttpSession(true)
                .permitAll()
                .and()
                .rememberMe()
                .key(REMEMBERME_KEY)
                .tokenValiditySeconds(60 * 60 * 24 * 30);
    }

    @Bean
    public AuthenticationProvider daoAuhthenticationProvider() {
        DaoAuthenticationProvider daoAuthenticationProvider = new DaoAuthenticationProvider();
        daoAuthenticationProvider.setUserDetailsService(userDetailsService);
        daoAuthenticationProvider.setHideUserNotFoundExceptions(false);
        daoAuthenticationProvider.setPasswordEncoder(new BCryptPasswordEncoder());
        return daoAuthenticationProvider;
    }

    @Autowired
    public void configAuthentication(AuthenticationManagerBuilder auth) throws Exception {
        auth.authenticationProvider(daoAuhthenticationProvider())
                .userDetailsService(userDetailsService);
    }

}
  • public static final String REMEMBERME_KEY = "ksbysample-webapp-lending"; を追加します。
  • .key("ksbysample-webapp-lending").key(REMEMBERME_KEY) へ変更します。

LendingUserDetailsService.java

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        UserInfo userInfo = userInfoDao.selectByMailAddress(username);
        if (userInfo == null) {
            throw new UsernameNotFoundException(
                    messageSource.getMessage("UserInfoUserDetailsService.usernameNotFound"
                            , null, LocaleContextHolder.getLocale()));
        }

        Set<SimpleGrantedAuthority> authorities = new HashSet<>();
        List<UserRole> userRoleList = userRoleDao.selectByUserId(userInfo.getUserId());
        if (userRoleList != null) {
            authorities.addAll(
                    userRoleList.stream()
                            .map(userRole -> new SimpleGrantedAuthority(userRole.getRole()))
                            .collect(Collectors.toList()));
        }

        return new LendingUserDetails(userInfo, authorities);
    }
  • messageSource.getMessage("UserInfoUserDetailsService.usernameNotFound", new Object[]{}, LocaleContextHolder.getLocale())); の第2引数を new Object[]{}null へ変更します。
  • userRoleList.stream().map(userRole -> new SimpleGrantedAuthority(userRole.getRole())).map(...) の前で改行します。

履歴

2015/08/04
初版発行。