かんがるーさんの日記

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

Spring Boot でログイン画面 + 一覧画面 + 登録画面の Webアプリケーションを作る ( その8 )( ログイン画面作成 )

概要

Spring Boot でログイン画面 + 一覧画面 + 登録画面の Webアプリケーションを作る ( その7 )( 検索/一覧画面 ( MyBatis-Spring版 ) 作成2 ) の続きです。

  • 今回の手順で確認できるのは以下の内容です。
    • ログイン画面の作成、確認 ( ログアウト機能もここで作ります )
    • ログインに使用する ID、パスワードは DB に保存します。ただし今回は DB に保存するパスワードは暗号化しません。
  • Spring Security を使用する時の内容をまとめると以下のようになります。
    1. 認証処理で使用するテーブルを DB に作成します。
    2. WebSecurityConfigurerAdapter を継承した Java Configuration なクラスを作成し、configure(HttpSecurity http) メソッドの中で認証を必要とする URL の設定や login, logout の URL の設定等を、configAuthentication(AuthenticationManagerBuilder auth) メソッドの中で DB を使用した認証に関する設定をします。
    3. ログイン画面を作成し、ログイン処理の URL を configure(HttpSecurity http) で設定した URL ( 今回の場合 /login ) にします。またログイン時の URL は th:actioin で記述し、送信方法は POST にします。
    4. ログアウト時の URL を configure(HttpSecurity http) で設定した URL ( 今回の場合 /logout ) にします。

ソフトウェア一覧

参考にしたサイト

  1. Kielczewski.eu - Spring Boot Security Application
    http://kielczewski.eu/2014/12/spring-boot-security-application/

    • Spring Boot + Spring Security の使い方が分かりやすく書いてあります。
    • また Spring Boot で Java 8 の新機能で書く場合のサンプルとしても参考になります。
  2. SpringSecurityCustomMessages
    https://code.google.com/p/uclm-esi-alarcos/wiki/SpringSecurityCustomMessages

    • Spring Security のメッセージを日本語化する方法について参考にしました。

手順

1.0.x-makelogin ブランチの作成

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

build.gradle の編集、反映

  1. build.gradle をエディタで開き、リンク先の内容 に変更します。変更後、Gradle tasks View の「Refresh Gradle projects」アイコンをクリックして、変更した build.gradle の内容を反映します。

user, user_role テーブルの作成、データ登録

  1. リンク先のSQL を実行して user, user_role テーブルを作成し、動作確認時に使用するデータも登録します。

WebSecurityConfig クラスの作成

  1. src/main/java/ksbysample/webapp/basic/config の下に WebSecurityConfig.java を作成します。作成後、リンク先の内容に変更します。

login.html の変更

  1. src/main/resources/templates の下の login.html をエディタで開き、リンク先の内容 に変更します。

認証エラー時のメッセージの日本語化

  1. src/main/java/ksbysample/webapp/basic/config の下の ApplicationConfig.java をエディタで開き、リンク先の内容 に変更します。

  2. src/main/resources の下に messages_ja_JP.properties を作成します。作成後、リンク先の内容に変更します。

動作確認

  1. Gradle tasks View から bootRun を実行します。

  2. ブラウザで http://localhost:8080/ にアクセスしてログイン画面を表示した後、test / test を入力して「ログイン」ボタンをクリックします。認証エラーとなり、"入力された ID あるいはパスワードが正しくありません" と日本語のエラーメッセージが表示されます。

    f:id:ksby:20150129183838p:plain

  3. test / ptest を入力して「ログイン」ボタンをクリックします。認証が通り http://localhost:8080/countryList へ遷移して検索/一覧画面が表示されます。画面右上の「ログアウト」リンクをクリックします。

  4. 再び http://localhost:8080/ に戻りログイン画面が表示されます。ここでブラウザのアドレスバーに http://http://localhost:8080/countryList と入力してもログインしていないので http://localhost:8080/ へ遷移してログイン画面が表示されます ( 認証が必要と設定されている画面にログインしていない状態でアクセスした場合にはログイン画面が表示されます )。

  5. また今の実装だけで、認証が必要な URL にログインしていない状態でアクセスする→ログイン画面が表示される→ログインする→最初にアクセスしようとしていた URL のページが表示される、という動作も実現されています。試してみます。

  6. ブラウザのアドレスバーに http://localhost:8080/country/input を入力します。ログインが必要な URL ですが、ログインしていないので http://localhost:8080/ へ遷移してログイン画面が表示されます。

  7. test / ptest を入力して「ログイン」ボタンをクリックします。

  8. http://localhost:8080/countryList ではなく http://localhost:8080/country/input の URL へ遷移して登録画面(入力)が表示されます。

countryList.html の変更

  1. 実は Spring Security を入れると検索/一覧画面のページネーションの部分が動作しなくなります。Spring Security は、認証が必要と設定された画面の HTML 内の </form> タグの直前に CSRF対策として <input type="hidden" name="_csrf" value="007a5652-e679-481b-bc19-856117a5b118" /> のような input タグを自動で埋め込むのですが、ページネーション用に作成した <form id="pagenationForm" ...> ... </form> の中には自動で埋め込まれていないためです ( 自動的に埋め込まれない原因は分かりませんでした )。動作させるために、あらかじめ Thymeleaf テンプレートファイル内に _csrf の input タグを埋め込みます。

  2. src/main/resources/templates の下の countryList.html をエディタで開き、リンク先の内容 に変更します。

  3. 検索/一覧画面を表示し、ページネーションが動作することを確認します。

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

commit する

  1. commit します。

    f:id:ksby:20150129185209p:plain

  2. 「Code Analysis」ダイアログが表示されますので、「Review」ボタンをクリックします。

  3. "Class ( あるいは Method ) ... is never used" の Warning メッセージは今回は全て無視します。

  4. 再度 commit し、「Code Analysis」ダイアログが表示されたら「Commit」ボタンをクリックします。

GitHub へ Push、1.0.x-makelogin -> 1.0.x へ Pull Request、1.0.x でマージ、1.0.x-makelogin ブランチを削除

  1. IntelliJ IDEA から GitHub へ Push します。

  2. GitHub で Pull Request を作成します。

  3. IntelliJ IDEA で 1.0.x へ切り替えた後、1.0.x-makelogin を merge します。

  4. GitHub へ Push します。

  5. ローカル及び GitHub の 1.0.x-makelogin を削除します。

次回は。。。

以下に記載したもののうち、出来るところまで対応します。

  • パスワードを暗号化します。
  • ログインエラー時に ID がクリアされるので、ID を残す方法があるか確認します。
  • 存在しない URL ( http://localhost:8080/test ) へアクセスする→ログイン画面が表示される→ログインする→404(Not Found)が返る、という動作になるので、存在しない URL の場合にはログイン画面を表示せずに最初から 404 を返すか、ログインした後デフォルトページ ( http://localhost:8080/countryList ) に遷移させる方法を確認します。
  • エラーメッセージで用意されている locked, expired, credentialsExpired を出す方法を確認してみます。
  • 検索/一覧画面のページネーション用に作成した form に自動で CSRF対策用の hidden が出力されない原因を調査します。
  • テストクラスを作成・修正します。現在、検索/一覧画面のテストクラスが _csrf.token を記述している部分が原因でエラーになります。

ソースコード

build.gradle

dependencies {
    compile("org.springframework.boot:spring-boot-starter-web:1.2.1.RELEASE")
    compile("org.springframework.boot:spring-boot-starter-thymeleaf")
    compile("org.springframework.boot:spring-boot-starter-data-jpa")
    compile("org.springframework.boot:spring-boot-starter-security")
    compile("mysql:mysql-connector-java:5.1.34")
    compile("org.mybatis:mybatis:3.2.8")
    compile("org.mybatis:mybatis-spring:1.2.2")
    compile("org.bgee.log4jdbc-log4j2:log4jdbc-log4j2-jdbc4.1:1.16")
    compile("org.codehaus.janino:janino:2.7.5")
    compile("org.apache.commons:commons-lang3:3.3.2")
    compile("org.projectlombok:lombok:1.14.8")
    testCompile("org.springframework.boot:spring-boot-starter-test")
}
  • compile("org.springframework.boot:spring-boot-starter-security") を追加します。この定義が追加されると、あらかじめ静的リソースの配置先として想定されている /css, /js 等の URL 以外は Basic認証のダイアログが表示されるようになりますので、認証不要の時はこの定義を追加しないようにして下さい。

create_table.sql

create table user (
    id              varchar(32)     not null
    , password      varchar(128)    not null
    , enabled       tinyint         not null default 1
    , constraint pk_user primary key (id)
);

create table user_role (
    id              varchar(32)         not null
    , role          varchar(32)         not null
    , constraint pk_user_role primary key (id)
);

insert into user values('test', 'ptest', 1);
insert into user_role values('test', 'USER');
commit;

WebSecurityConfig.java

package ksbysample.webapp.basic.config;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.autoconfigure.security.SecurityProperties;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.annotation.Order;
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.web.util.matcher.AntPathRequestMatcher;

import javax.sql.DataSource;

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

    @Autowired
    private DataSource dataSource;

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

    @Autowired
    public void configAuthentication(AuthenticationManagerBuilder auth) throws Exception {
        auth.jdbcAuthentication().dataSource(dataSource)
                .usersByUsernameQuery("select id, password, enabled from user where id = ?")
                .authoritiesByUsernameQuery("select id, role from user_role where id = ?");
    }

}
  • @Order(SecurityProperties.ACCESS_OVERRIDE_ORDER) を記述すると Spring Security がデフォルトで設定する Basic認証が機能しなくなります。
  • .logoutRequestMatcher(new AntPathRequestMatcher("/logout")) の部分は最初 .logoutUrl("/logout") と書いていたのですが動作しませんでした。原因は後者の書き方にする場合 /logout の呼び出しを POST にする必要があるためらしいです。リクエストの送信方法が GET の場合には前者の書き方になります。

login.html

<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml"
      xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="utf-8"/>
    <meta http-equiv="X-UA-Compatible" content="IE=edge"/>
    <meta name="viewport" content="width=device-width, initial-scale=1"/>

    <title>ログイン</title>

    <!-- Bootstrap core CSS -->
    <link href="/css/bootstrap.min.css" rel="stylesheet"/>

    <!-- HTML5 shim and Respond.js for IE8 support of HTML5 elements and media queries -->
    <!--[if lt IE 9]>
    <script src="/js/html5shiv.min.js"></script>
    <script src="/js/respond.min.js"></script>
    <![endif]-->

    <!-- Custom styles for this template -->
    <style>
    <!--
    body {
        padding-top: 50px;
    }
    .navbar-brand {
        font-size: 24px;
    }
    .form-group {
        margin-bottom: 5px;
    }
    -->
    </style>
</head>

<body>
    <div class="container">
        <div class="row">
            <div class="col-xs-12 col-md-push-3 col-md-6">
                <div class="form-wrap">
                    <div class="text-center"><h1>WebApp - Basic</h1></div>
                    <br/>
                    <form role="form" action="#" th:action="@{/login}" method="post" id="login-form" autocomplete="off">
                        <div class="form-group" th:if="${session['SPRING_SECURITY_LAST_EXCEPTION']} != null">
                            <p class="form-control-static text-danger" th:text="${session['SPRING_SECURITY_LAST_EXCEPTION'].message}"></p>
                        </div>
                        <div class="form-group">
                            <label for="id" class="sr-only">ID</label>
                            <input type="text" name="id" id="id" class="form-control" placeholder="ID を入力して下さい"/>
                        </div>
                        <div class="form-group">
                            <label for="password" class="sr-only">Password</label>
                            <input type="password" name="password" id="password" class="form-control" placeholder="Password を入力して下さい"/>
                        </div>
                        <br/>
                        <button id="btn-login" class="btn btn-primary btn-block">ログイン</button>
                    </form>
                </div>
            </div>
        </div>
    </div>

    <!-- Bootstrap core JavaScript
    ================================================== -->
    <!-- Placed at the end of the document so the pages load faster -->
    <script src="/js/jquery.min.js"></script>
    <script src="/js/bootstrap.min.js"></script>
    <!-- IE10 viewport hack for Surface/desktop Windows 8 bug -->
    <script src="/js/ie10-viewport-bug-workaround.js"></script>

    <script type="text/javascript">
    <!--
    $(document).ready(function() {
        $('#id').focus();
    });
    -->
    </script>
</body>
</html>
  • <html lang="ja"><html xmlns="http://www.w3.org/1999/xhtml" xmlns:th="http://www.thymeleaf.org"> へ変更します。
  • form の action の URL を /countryList → `# へ変更します。
  • th:action="@{/login}" を追加します。
  • ログインエラー時のエラーメッセージを表示するために <div class="form-group" th:if="${session['SPRING_SECURITY_LAST_EXCEPTION']} != null"> ... </div> を追加します。

ApplicationConfig.java

    @Bean
    public MessageSource messageSource() {
        ReloadableResourceBundleMessageSource messageSource = new ReloadableResourceBundleMessageSource();
        messageSource.setBasename("classpath:messages");
        return messageSource;
    }
  • messageSource メソッドを追加します。メッセージファイル名は message.properties の想定です。

messages_ja_JP.properties

AbstractUserDetailsAuthenticationProvider.locked=入力された ID はロックされています
AbstractUserDetailsAuthenticationProvider.disabled=入力された ID は使用できません
AbstractUserDetailsAuthenticationProvider.expired=入力された ID の有効期限が切れています
AbstractUserDetailsAuthenticationProvider.credentialsExpired=入力された ID のパスワードの有効期限が切れています
AbstractUserDetailsAuthenticationProvider.badCredentials=入力された ID あるいはパスワードが正しくありません
  • 今回の実装で確認できるのは、AbstractUserDetailsAuthenticationProvider.disabled と AbstractUserDetailsAuthenticationProvider.badCredentials の2つだけです。他のエラーメッセージが表示されるようには実装されていません。

countryList.html

        <form id="pagenationForm" method="post" action="#" th:object="${countryListForm}">
            <input type="hidden" name="code" id="code" th:value="*{code}"/>
            <input type="hidden" name="name" id="name" th:value="*{name}"/>
            <input type="hidden" name="continent" id="continent" th:value="*{continent}"/>
            <input type="hidden" name="localName" id="localName" th:value="*{localName}"/>
            <input type="hidden" name="_csrf" th:value="${_csrf.token}"/>
        </form>
  • <input type="hidden" name="_csrf" th:value="${_csrf.token}"/> を追加します。

履歴

2015/01/29
初版発行。