かんがるーさんの日記

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

Spring Boot で書籍の貸出状況確認・貸出申請する Web アプリケーションを作る ( その12 )( Spring Session を使用する )

概要

Spring Boot で書籍の貸出状況確認・貸出申請する Web アプリケーションを作る ( その11 )( ログイン画面の作成5 ) の続きです。

  • 今回の手順で確認できるのは以下の内容です。
    • セッションの管理に Spring Session を使用する

参照したサイト・書籍

  1. Spring Session
    http://projects.spring.io/spring-session/

    • Spring Session の公式サイトです。
  2. Spring Bootハンズオン - 5. Spring Sessionの導入
    http://jsug-spring-boot-handson.readthedocs.org/en/latest/SpringSession.html

    • Spring Boot で Spring Session を使用するための設定方法を参照しました。

目次

  1. はじめに
  2. 1.0.x-use-spring-session ブランチの作成
  3. Spring Session を使用していないとどうなるのか?
  4. Spring Session を使用するための設定・実装
  5. 動作確認
  6. User 情報をセットするクラスを新規作成して Serializable インターフェースを宣言する
  7. 動作確認2
  8. 動作確認3 ( 今度は Redis のデータを見ながら )
  9. 動作確認4 ( Redis のデータが消えるとどうなるのか? )
  10. 次回は。。。

手順

はじめに

Spring Session を使用することでセッションデータを Tomcat のメモリではなく Redis で管理するようにしてみます。

これにより以下の作業が不要になったり効果が出たりして、スケールアウトしたい場合には単に Tomcat を増やして転送先として設定すればよいだけになるはずです。

  • Apache や Nginx の Web サーバでスティッキーセッションの設定をして、セッションを生成した Tomcat にリクエストを転送する必要がなくなります。
  • Tomcat 間でセッションレプリケーションをする必要もなくなります。
  • Tomcat を再起動したらセッションデータがなくなってログアウトさせられることがなくなります。

1.0.x-use-spring-session ブランチの作成

  1. IntelliJ IDEA で 1.0.x-use-spring-session ブランチを作成します。

Spring Session を使用していないとどうなるのか?

Spring Session を使用していない今の実装だと、ログイン後に Tomcat が再起動されるとサーバ側でメモリ内に保持していたセッション情報が破棄されるため、次にアクセスした時にログイン画面が表示されます。試してみます。

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

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

    f:id:ksby:20150725131344p:plain

  3. Ctrl+F5 を押して Tomcat を再起動します。

  4. ブラウザでリロードすると、セッション情報が破棄されているため認証されていないと判断されてログイン画面が表示されます。

    f:id:ksby:20150725131528p:plain

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

また2台以上 Tomcat を起動している状態で、セッションを生成した Tomcat でない方の Tomcat にリクエストが送信されると認証されていないと判断されてログイン画面が表示されます ( こちらは試しません )。

Spring Session を使用するための設定・実装

Spring Session が使用されるようにします。

  1. build.gradle を変更して必要なライブラリをダウンロードします。build.gradle を リンク先の内容 に変更します。

  2. Gradle projects View の左上にある「Refresh all Gradle projects」アイコンをクリックして、変更した build.gradle の内容を反映します。

  3. src/main/java/ksbysample/webapp/lending の下の Application.javaリンク先の内容 に変更します。

  4. src/main/resources の下の application-develop.properties, application-unittest.properties, application-product.properties を リンク先の内容 に変更します。

動作確認

動作確認します。

  1. Redis は Spring Boot で書籍の貸出状況確認・貸出申請する Web アプリケーションを作る ( その2 )( Redis ( Windows 版 ) のインストール ) でインストールしてサービスで起動したままにしています。もし起動していない場合には起動します。

    f:id:ksby:20150725151311p:plain

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

  3. ブラウザで http://localhost:8080/ にアクセスし、ログイン画面を表示します。ID に "tanaka.taro@sample.com"、Password に "taro" を入力し、「次回から自動的にログインする 」はチェックせず「ログイン」ボタンをクリックします。

    ログインできずエラーになりました。。。

    f:id:ksby:20150725151517p:plain

  4. ログを見ると Caused by: java.io.NotSerializableException: ksbysample.webapp.lending.entity.UserInfo のエラーログが出力されていました。Redis にセッション情報を保存するので、保存するクラスに Serializable インターフェースを宣言する必要があったようです。

User 情報をセットするクラスを新規作成して Serializable インターフェースを宣言する

UserInfo クラスは DomaGen が自動生成する Entity クラスなので、このクラスに Serializable インターフェースは宣言しません。以下の方針で変更します。

  • LendingUser クラスを新規作成し、このクラスに Serializable インターフェースを宣言します。フィールドは UserInfo クラスから userId 以外のものを全て実装します。
  • クラス名を UserInfoUserDetails → LendingUserDetails へ、UserInfoUserDetailsService → LendingUserDetailsService へ変更します。
  • LendingUserDetails クラス ( 旧 UserInfoUserDetails クラス ) のフィールドの内、UserInfo クラスを LendingUser クラスへ変更します。ただし LendingUserDetails クラスのコンストラクタの引数は UserInfo クラス のままにします ( コンストラクタ内で UserInfo → LendingUser へ変換します )。

実装します。

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

  2. src/main/java/ksbysample/webapp/lending/security の下の UserInfoUserDetails.java を LendingUserDetails.java にリネームします。リネーム後、リンク先の内容 に変更します。

  3. src/main/java/ksbysample/webapp/lending/security の下の UserInfoUserDetailsService.java を LendingUserDetailsService.java にリネームします。

動作確認2

  1. Ctrl+F5 を押して Tomcat を再起動します。

  2. ブラウザで http://localhost:8080/ にアクセスし、ログイン画面を表示します。ID に "tanaka.taro@sample.com"、Password に "taro" を入力し、「次回から自動的にログインする 」はチェックせず「ログイン」ボタンをクリックします。

    今度は「ログイン成功!」の画面が表示されました。

    f:id:ksby:20150725161131p:plain

  3. Ctrl+F5 を押して Tomcat を再起動します。

  4. ブラウザでリロードします。ログイン画面へ遷移せず、再度「ログイン成功!」の画面が表示されました。

    f:id:ksby:20150725161450p:plain

  5. ブラウザで http://localhost:8080/logout にアクセスしログアウトします。

  6. ブラウザで http://localhost:8080/loginsuccess にアクセスします。ログアウトでセッション情報が削除されているので、今度はログイン画面に遷移しました。

  7. Ctrl+F5 を押して Tomcat を停止します。

動作確認3 ( 今度は Redis のデータを見ながら )

Resis にデータがどのように作成されているのか確認します。

  1. ブラウザを起動していたら一旦終了させます。

  2. コマンドラインから redis-cli コマンドで Redis 上のデータをクリアします。

    f:id:ksby:20150725200000p:plain

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

  4. ブラウザを起動して http://localhost:8080/ にアクセスし、ログイン画面を表示します。

  5. Redis 上のデータを確認するとデータが作成されていました。ログイン画面にアクセスした時点でセッション ID が発行されているようです。

    f:id:ksby:20150725200602p:plain

  6. ID に "tanaka.taro@sample.com"、Password に "taro" を入力し、「次回から自動的にログインする 」はチェックせず「ログイン」ボタンをクリックします。認証に成功し「ログイン成功!」の画面が表示されます。

  7. Redis 上のデータを確認すると "spring:session:sessions:d2b3e547-0bf1-497a-9807-de124adfe016" が消えて "spring:session:sessions:59ca4193-6ffc-4b84-be1a-0e817d1526fe""spring:session:expirations:1437824400000" のデータが追加されていました。セッション ID を削除した後、新規セッション ID が発行されるようです。

    f:id:ksby:20150725201033p:plain

  8. Ctrl+F5 を押して Tomcat を再起動します。

  9. Redis のデータは何も変わりませんでした。

    f:id:ksby:20150725201548p:plain

  10. ブラウザでリロードして「ログイン成功!」の画面を表示し直します。

  11. Redis のデータは "spring:session:expirations:1437824400000" が消えて "spring:session:expirations:1437824820000" が追加されていました。

    f:id:ksby:20150725201733p:plain

  12. ブラウザで http://localhost:8080/logout にアクセスしログアウトして、ログイン画面に戻ります。

  13. Redis のデータは "spring:session:sessions:59ca4193-6ffc-4b84-be1a-0e817d1526fe" が削除されて "spring:session:sessions:e4886eba-d984-4349-99d7-8435e30ccc05""spring:session:expirations:1437824940000" が追加されていました。ログアウト時にセッション ID が削除されますが、ログイン画面にアクセスした時に新規セッション ID が発行されるようです。

    f:id:ksby:20150725201955p:plain

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

  15. Redis のデータは変わりませんでした。

    f:id:ksby:20150725204115p:plain

  16. ログイン画面にアクセスした時にまでセッション ID を発行しなくてもよいのでは? と思いましたが、表示されたログイン画面のソースを見たら <input type="hidden" name="_csrf" value="c1b41fc6-7375-43e8-88e1-d6e624797a5f" /> が出力されていました。CSRF 対策のためのサーバ側データを保持するためにセッション ID が発行されていたようです ( Spring Session ではなく Spring Security の機能です )。CSRF 対策も重要ですが、ログイン画面にアクセスされる度にセッションデータが Redis に作成されるのは何かいやですね。。。

動作確認4 ( Redis のデータが消えるとどうなるのか? )

Redis のデータを手動で削除するとどのような挙動になるか確認してみます。

  1. ブラウザを起動していたら一旦終了させます。

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

  3. ブラウザを起動して http://localhost:8080/ にアクセスし、ログイン画面を表示します。

  4. コマンドラインから redis-cli コマンドで Redis 上のデータをクリアします。

    f:id:ksby:20150726024127p:plain

  5. ID に "tanaka.taro@sample.com"、Password に "taro" を入力し、「次回から自動的にログインする 」はチェックせず「ログイン」ボタンをクリックします。

    エラー画面が表示されました。画面上に Expected CSRF token not found. Has your session expired? と表示されている通り、サーバの CSRF トークンを削除したのが原因ですね。

    f:id:ksby:20150726024315p:plain

  6. 再度ブラウザで http://localhost:8080/ にアクセスし、ログイン画面を表示します。ID に "tanaka.taro@sample.com"、Password に "taro" を入力し、「次回から自動的にログインする 」はチェックせず「ログイン」ボタンをクリックします。認証に成功し「ログイン成功!」の画面が表示されます。

  7. Redis 上のデータをクリアします。

    f:id:ksby:20150726025125p:plain

  8. ブラウザでリロードします。サーバ側のセッションデータを削除しているのでログイン画面へ遷移しました。

    f:id:ksby:20150726025233p:plain

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

次回は。。。

Spring Session を使用した場合の検証を続けます。以下の検証をする予定です。また今回変更しているソースは次回マージします。

  • ログイン画面及びログイン URL ( /login ) で CSRF 対策を外す方法があるのか?
  • Redis を複数用意した場合を試してみる。

ソースコード

build.gradle

dependencies {
    def jdbcDriver = "org.postgresql:postgresql:9.4-1201-jdbc41"

    // spring-boot-gradle-plugin によりバージョン番号が自動で設定されるもの
    // Appendix E. Dependency versions ( http://docs.spring.io/spring-boot/docs/current/reference/html/appendix-dependency-versions.html ) 参照
    compile("org.springframework.boot:spring-boot-starter-web")
    compile("org.springframework.boot:spring-boot-starter-thymeleaf")
    compile("org.thymeleaf.extras:thymeleaf-extras-springsecurity3")
    compile("org.springframework.boot:spring-boot-starter-data-jpa")
    compile("org.springframework.boot:spring-boot-starter-velocity")
    compile("org.springframework.boot:spring-boot-starter-mail")
    compile("org.springframework.boot:spring-boot-starter-security")
    compile("org.springframework.boot:spring-boot-starter-redis")
    compile("org.codehaus.janino:janino")
    testCompile("org.springframework.boot:spring-boot-starter-test")
    testCompile("org.springframework.security:spring-security-test:4.0.1.RELEASE")
    testCompile("org.yaml:snakeyaml")

    // spring-boot-gradle-plugin によりバージョン番号が自動で設定されないもの
    compile("${jdbcDriver}")
    compile("org.seasar.doma:doma:2.3.1")
    compile("org.bgee.log4jdbc-log4j2:log4jdbc-log4j2-jdbc4.1:1.16")
    compile("org.apache.commons:commons-lang3:3.4")
    compile("org.projectlombok:lombok:1.16.4")
    compile("com.google.guava:guava:18.0")
    compile("org.springframework.session:spring-session:1.0.1.RELEASE")
    testCompile("org.dbunit:dbunit:2.5.1")
    testCompile("com.icegreen:greenmail:1.4.1")

    // for Doma-Gen
    domaGenRuntime("org.seasar.doma:doma-gen:2.3.1")
    domaGenRuntime("${jdbcDriver}")
}
  • compile("org.springframework.boot:spring-boot-starter-redis") を追加します。
  • compile("org.springframework.session:spring-session:1.0.1.RELEASE") を追加します。

Application.java

package ksbysample.webapp.lending;

import org.apache.commons.lang3.StringUtils;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.annotation.ImportResource;
import org.springframework.session.data.redis.config.annotation.web.http.EnableRedisHttpSession;

import java.text.MessageFormat;

@ImportResource("classpath:applicationContext-${spring.profiles.active}.xml")
@SpringBootApplication
@EnableRedisHttpSession
public class Application {

    public static void main(String[] args) {
        String springProfilesActive = System.getProperty("spring.profiles.active");
        if (!StringUtils.equals(springProfilesActive, "product")
                && !StringUtils.equals(springProfilesActive, "develop")
                && !StringUtils.equals(springProfilesActive, "unittest")) {
            throw new UnsupportedOperationException(MessageFormat.format("JVMの起動時引数 -Dspring.profiles.active で develop か unittest か product を指定して下さい ( -Dspring.profiles.active={0} )。", springProfilesActive));
        }

        SpringApplication.run(Application.class, args);
    }

}
  • @EnableRedisHttpSession を追加します。

application-develop.properties, application-unittest.properties, application-product.properties

■application-develop.properties

spring.datasource.url=jdbc:log4jdbc:postgresql://localhost/ksbylending
spring.datasource.username=ksbylending_user
spring.datasource.password=xxxxxxxx
spring.datasource.driverClassName=net.sf.log4jdbc.sql.jdbcapi.DriverSpy

spring.mail.host=localhost
spring.mail.port=25

spring.redis.host=localhost
spring.redis.port=6379

spring.messages.cache-seconds=0
spring.thymeleaf.cache=false
spring.velocity.cache=false
  • spring.redis.host=localhost, spring.redis.port=6379 を追加します。ただしこの2つの設定はデフォルトで localhost, 6379 が設定されており、Redis が起動しているのが localhost:6379 なのであれば設定しなくても動作します。

■application-unittest.properties

spring.datasource.url=jdbc:postgresql://localhost/ksbylending
spring.datasource.username=ksbylending_user
spring.datasource.password=xxxxxxxx
spring.datasource.driverClassName=org.postgresql.Driver

spring.mail.host=localhost
spring.mail.port=25

spring.redis.host=localhost
spring.redis.port=6379

spring.thymeleaf.cache=true
  • spring.redis.host=localhost, spring.redis.port=6379 を追加します。

■application-product.properties

server.tomcat.basedir=C:/webapps/ksbysample-webapp-lending

spring.datasource.url=jdbc:postgresql://localhost/ksbylending
spring.datasource.username=ksbylending_user
spring.datasource.password=xxxxxxxx
spring.datasource.driverClassName=org.postgresql.Driver

spring.mail.host=localhost
spring.mail.port=25

spring.redis.host=localhost
spring.redis.port=6379

spring.thymeleaf.cache=true
  • spring.redis.host=localhost, spring.redis.port=6379 を追加します。

LendingUser.java

package ksbysample.webapp.lending.security;

import ksbysample.webapp.lending.entity.UserInfo;
import lombok.Data;
import org.springframework.beans.BeanUtils;

import java.io.Serializable;
import java.time.LocalDateTime;

@Data
public class LendingUser implements Serializable {

    String username;

    String password;

    String mailAddress;

    Short enabled;

    Short cntBadcredentials;

    LocalDateTime expiredAccount;

    LocalDateTime expiredPassword;

    public LendingUser(UserInfo userInfo) {
        BeanUtils.copyProperties(userInfo, this);
    }
    
}

LendingUserDetails.java

package ksbysample.webapp.lending.security;

import ksbysample.webapp.lending.entity.UserInfo;
import org.springframework.beans.BeanUtils;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;

import java.time.LocalDateTime;
import java.util.Collection;
import java.util.Set;

public class LendingUserDetails implements UserDetails {

    private LendingUser lendingUser;
    private final Set<? extends GrantedAuthority> authorities;
    private final boolean accountNonExpired;
    private final boolean accountNonLocked;
    private final boolean credentialsNonExpired;
    private final boolean enabled;

    public LendingUserDetails(UserInfo userInfo
            , Set<? extends GrantedAuthority> authorities) {
        LocalDateTime now = LocalDateTime.now();
        lendingUser = new LendingUser(userInfo);
        this.authorities = authorities;
        this.accountNonExpired = !userInfo.getExpiredAccount().isBefore(now);
        this.accountNonLocked = (userInfo.getCntBadcredentials() < 5);
        this.credentialsNonExpired = !userInfo.getExpiredPassword().isBefore(now);
        this.enabled = (userInfo.getEnabled() == 1);
    }

    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        return authorities;
    }

    @Override
    public String getPassword() {
        return lendingUser.getPassword();
    }

    @Override
    public String getUsername() {
        return lendingUser.getMailAddress();
    }

    public String getName() {
        return lendingUser.getUsername();
    }

    @Override
    public boolean isAccountNonExpired() {
        return accountNonExpired;
    }

    @Override
    public boolean isAccountNonLocked() {
        return accountNonLocked;
    }

    @Override
    public boolean isCredentialsNonExpired() {
        return credentialsNonExpired;
    }

    @Override
    public boolean isEnabled() {
        return enabled;
    }
}
  • private UserInfo userInfoprivate LendingUser lendingUser; へ変更します。
  • コンストラクタ内の処理を this.userInfo = userInfo;lendingUser = new LendingUser(userInfo); へ変更します。

履歴

2015/07/26
初版発行。