kagamihogeの日記

kagamihogeの日記です。

Spring Batch 5さわる

Spring Batchは5.0から色々な変更が入った事を最近知った。

docs.spring.io

www.slideshare.net

なので、良くやる設定などでとりあえず触ってみる。何分初めてさわるバージョンなので間違ってる事書いていたら申し訳ない。

build.gradle

plugins {
    id 'java'
    id 'org.springframework.boot' version '3.0.4'
    id 'io.spring.dependency-management' version '1.1.0'
}

group = 'com.example'
version = '0.0.1-SNAPSHOT'
sourceCompatibility = '17'

configurations {
    compileOnly {
        extendsFrom annotationProcessor
    }
}

repositories {
    mavenCentral()
}

dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-batch'
    compileOnly 'org.projectlombok:lombok'
    runtimeOnly 'com.h2database:h2'
    annotationProcessor 'org.projectlombok:lombok'
    testImplementation 'org.springframework.boot:spring-boot-starter-test'
    testImplementation 'org.springframework.batch:spring-batch-test'
}

tasks.named('test') {
    useJUnitPlatform()
}

hello worldレベル

まずsystem.outするtaskletが一つだけのjobを作成する。

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
public class SampleBatch5App {
  public static void main(String[] args) {
    SpringApplication.run(SampleBatch5App.class, args);
  }
}
import org.springframework.batch.core.Job;
import org.springframework.batch.core.Step;
import org.springframework.batch.core.job.builder.JobBuilder;
import org.springframework.batch.core.repository.JobRepository;
import org.springframework.batch.core.step.builder.StepBuilder;
import org.springframework.batch.repeat.RepeatStatus;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.transaction.PlatformTransactionManager;

@Configuration
public class SampleBatch5Config {
  @Bean
  Job sampleJob(JobRepository jobRepository, PlatformTransactionManager txManager) {
    Step step = new StepBuilder("sampleStep", jobRepository)
        .tasklet((contribution, chunkContext) -> {
          System.out.println("sample job step");
          return RepeatStatus.FINISHED;
        }, txManager)
          .build();

    return new JobBuilder("sampleJob", jobRepository)
        .start(step)
          .build();
  }
}

データソースは依存性にh2があれば自動的にそれに対して生成され、spring-batchのメタデータ用テーブルもそちらに自動的に生成される。transaction-managerも同様なので引数に指定すれば自動生成されたものがinjectionされる。

MapJobRepositoryFactoryBeanは削除された。メタデータテーブル使用しない場合にspring-batch5ではどうするかだが、依存性にh2追加すればこうなるので、これが修正方法の第一候補になるだろうか?

複数のjob

以下のように複数jobが有る場合はspring.batch.job.nameプロパティで実行したいjobのbean名を指定する。VM引数の場合は-Dspring.batch.job.name=sampleJob1のように指定する。

@Configuration
public class SampleBatch5Config {

  @Bean("job1")
  Job sampleJob(JobRepository jobRepository, PlatformTransactionManager txManager) {
    Step step = new StepBuilder("sampleStep", jobRepository)
        .tasklet((contribution, chunkContext) -> {
          System.out.println("sample job 1");
          return RepeatStatus.FINISHED;
        }, txManager)
          .build();

    return new JobBuilder("sampleJob1", jobRepository)
        .start(step)
          .build();
  }

  @Bean("job2")
  Job sampleJob1(JobRepository jobRepository, PlatformTransactionManager txManager) {
    // 省略
  }

}

上述のプロパティを指定しない場合は下記のように実行時エラーになる。

Caused by: java.lang.IllegalArgumentException: Job name must be specified in case of multiple jobs
    at org.springframework.boot.autoconfigure.batch.JobLauncherApplicationRunner.validate(JobLauncherApplicationRunner.java:116) ~[spring-boot-autoconfigure-3.0.4.jar:3.0.4]

明示的なh2データソースの指定

複数データソース指定の前に、ひとまずauto-configではなく明示的にh2のデータソースを指定する。以下のように src/main/resources/application.properties に指定する。javaはhello-worldレベルと同じなので省略。

spring.datasource.url jdbc:h2:mem:sampledb;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=false
spring.datasource.username sa
spring.datasource.password 
spring.datasource.driverClassName org.h2.Driver

複数データソース

メインDBをmysqlメタデータをH2と想定する。ここの設定方法はspring-batch 5より前と変更無い。

mysqlJDBCドライバの依存性を追加する。

runtimeOnly 'com.mysql:mysql-connector-j'

データソース2つ分のプロパティを作成する。

spring.datasource.h2metadata.url jdbc:h2:mem:sampledb;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=false
spring.datasource.h2metadata.username sa
spring.datasource.h2metadata.password 
spring.datasource.h2metadata.driverClassName org.h2.Driver

spring.datasource.mysqlmain.driverClassName=com.mysql.cj.jdbc.Driver
spring.datasource.mysqlmain.url=jdbc:mysql://localhost:13306/mysql
spring.datasource.mysqlmain.username=root
spring.datasource.mysqlmain.password=password
import javax.sql.DataSource;

import org.springframework.batch.core.Job;
import org.springframework.batch.core.Step;
import org.springframework.batch.core.job.builder.JobBuilder;
import org.springframework.batch.core.repository.JobRepository;
import org.springframework.batch.core.step.builder.StepBuilder;
import org.springframework.batch.repeat.RepeatStatus;
import org.springframework.boot.autoconfigure.batch.BatchDataSource;
import org.springframework.boot.autoconfigure.jdbc.DataSourceProperties;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Primary;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.transaction.PlatformTransactionManager;

@Configuration
public class SampleBatch5Config {

  @Bean
  @ConfigurationProperties("spring.datasource.h2metadata")
  DataSourceProperties h2Properties() {
    return new DataSourceProperties();
  }

  @Bean
  @ConfigurationProperties("spring.datasource.mysqlmain")
  DataSourceProperties mysqlProperties() {
    return new DataSourceProperties();
  }

  @BatchDataSource
  @Bean
  DataSource h2DataSource() {
    return h2Properties()
        .initializeDataSourceBuilder()
          .build();
  }

  @Primary
  @Bean
  DataSource mysqlDataSource() {
    return mysqlProperties()
        .initializeDataSourceBuilder()
          .build();
  }

  @Bean
  JdbcTemplate jdbcTemplate() {
    return new JdbcTemplate(mysqlDataSource());
  }

  @Bean
  Job sampleJob(JobRepository jobRepository, PlatformTransactionManager mainTxManager) {
    System.out.println(mainTxManager);
    Step step = new StepBuilder("sampleStep", jobRepository)
        .tasklet((contribution, chunkContext) -> {
          System.out.println("asdf");
          jdbcTemplate().update("insert into sampletable(rname) values ('sample')");

          return RepeatStatus.FINISHED;
        }, mainTxManager)
          .build();

    return new JobBuilder("sampleJob", jobRepository)
        .start(step)
          .build();
  }
}

@ConfigurationPropertiesDataSourcePropertiesに各DB用設定をバインドする。さらにそれぞれの設定でDataSourceを作成する。メインであるmysqlには@Primaryを付与し、h2にはメタデータ用データソースを示す@BatchDataSourceを付与する。

transaction-managerは@Primaryを付与したmysqlデータソースに対して自動生成される。が、どちらのDBに対して自動生成されてるのかパッと見わかりにくいので、個人的には明示的にmysqlに対するJdbcTransactionManagerを作った方がいいかも……などと思う。

job parameter

job parameterのフォーマットが変更された。

@Configuration
public class SampleBatch5Config {
  @Bean
  Job sampleJob(JobRepository jobRepository, PlatformTransactionManager txManager) {
    Step step = new StepBuilder("sampleStep", jobRepository)
        .tasklet((contribution, chunkContext) -> {
          Map<String, Object> p = chunkContext.getStepContext().getJobParameters();

          System.out.println(p.get("hoge(int)")); // hogehoge
          System.out.println(p.get("localdate").getClass()); // class java.time.LocalDate
          System.out.println(p.get("localdatetime").getClass()); // class java.time.LocalDateTime

          return RepeatStatus.FINISHED;
        }, txManager)
          .build();

    return new JobBuilder("sampleJob", jobRepository)
        .start(step)
          .build();
  }
}

上記のstepでの引数の指定例は以下の通り。前バージョンのフォーマットparameterName(parameterType)のままだとhoge(int)のように型ごとkeyになる。

hoge(int)=hogehoge  \
intkey=10,java.lang.Integer  \
localdate=2022-12-12,java.time.LocalDate  \
localdatetime=2011-12-03T10:15:30,java.time.LocalDateTime

parameterName=parameterValue,parameterType,identificationFlagidentificationFlagtrue(デフォルト)だとジョブ実行のキー生成にこのパラメータが使用される。spring-batchの同一ジョブ実行判定はjob parameterが同一かどうかで行う。このフラグの導入により、あるjob parameterを同一判定に含むかどうかを切り替えられる。

job parameter(json)

json形式でjob parameterを渡すこともできる。

localdate={\"value\":\"2022-12-12\",\"type\":\"java.time.LocalDate\"}

jsonの場合はJobParametersConverterJsonJobParametersConverterで置き換える。

@Configuration
public class SampleBatch5Config {

  @Bean
  public JobParametersConverter jobParametersConverter() {
    return new JsonJobParametersConverter();
  }

 // ... 省略

DefaultBatchConfiguration

spring-batch全体の設定変更するDefaultBatchConfigurationが追加された。とりあえず使ってみる。

import javax.sql.DataSource;

import org.springframework.batch.core.Job;
import org.springframework.batch.core.Step;
import org.springframework.batch.core.configuration.support.DefaultBatchConfiguration;
import org.springframework.batch.core.explore.JobExplorer;
import org.springframework.batch.core.job.builder.JobBuilder;
import org.springframework.batch.core.launch.JobLauncher;
import org.springframework.batch.core.repository.JobRepository;
import org.springframework.batch.core.step.builder.StepBuilder;
import org.springframework.batch.repeat.RepeatStatus;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.boot.autoconfigure.batch.BatchDataSource;
import org.springframework.boot.autoconfigure.batch.BatchDataSourceScriptDatabaseInitializer;
import org.springframework.boot.autoconfigure.batch.BatchProperties;
import org.springframework.boot.autoconfigure.batch.JobLauncherApplicationRunner;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.boot.sql.init.dependency.DatabaseInitializationDependencyConfigurer;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Import;
import org.springframework.transaction.PlatformTransactionManager;
import org.springframework.util.StringUtils;

@Configuration
@EnableConfigurationProperties(BatchProperties.class)
@Import(DatabaseInitializationDependencyConfigurer.class)
public class SampleBatch5Config extends DefaultBatchConfiguration {

  @Bean
  public JobLauncherApplicationRunner jobLauncherApplicationRunner(JobLauncher jobLauncher,
      JobExplorer jobExplorer,
      JobRepository jobRepository, BatchProperties properties) {
    JobLauncherApplicationRunner runner = new JobLauncherApplicationRunner(jobLauncher, jobExplorer,
        jobRepository);
    String jobNames = properties.getJob().getName();
    if (StringUtils.hasText(jobNames)) {
      runner.setJobName(jobNames);
    }
    return runner;
  }

  @Configuration(proxyBeanMethods = false)
  static class DataSourceInitializerConfiguration {
    @Bean
    BatchDataSourceScriptDatabaseInitializer batchDataSourceInitializer(DataSource dataSource,
        @BatchDataSource ObjectProvider<DataSource> batchDataSource, BatchProperties properties) {
      return new BatchDataSourceScriptDatabaseInitializer(
          batchDataSource.getIfAvailable(() -> dataSource),
          properties.getJdbc());
    }
  }

  @Bean
  Job sampleJob(JobRepository jobRepository, PlatformTransactionManager txManager) {
    System.out.println(txManager);
    Step step = new StepBuilder("sampleStep", jobRepository)
        .tasklet((contribution, chunkContext) -> {
          System.out.println("sample job step");
          return RepeatStatus.FINISHED;
        }, txManager)
          .build();

    return new JobBuilder("sampleJob", jobRepository)
        .start(step)
          .build();
  }

}

ちなみに、上のサンプルコードは何も設定変更していない(=何も@overrideしてない)。

ハマった点として、DefaultBatchConfigurationのbeanがあるとspring-bootのBatchAutoConfigurationが無効になる。なので、spring.batch.job.nameのjob起動とか、メタデータテーブル自動生成とか、その辺が実行されない。なので、上のサンプルコードはその辺の設定をBatchAutoConfigurationからコピペしている。

とはいえ、ジョブ起動はJobLauncherだったり、メタデータmysqlとかに永続化してたり、色々なのでこの辺の設定はプロジェクトによってそれぞれと思われる。

Spring SecurityのForm認証

Spring BootSpring Securityを使う場合はauto-configurationで様々な設定が自動で行われ、そのサマリは 公式ドキュメントにある。よくあるForm認証はそこに設定を追加・修正で行う。また、デモ・開発用の設定が最初から有効なので何も設定無しでも一応の動作を確認できる。

以下のドキュメントはspring-security-6.0.2をベースにするため、以前のバージョンと比べて書き方が色々と変更されている点に注意が必要。詳細は以下の記事を参照*1

qiita.com

目次

プロジェクトの作成と動作確認

Spring Initializr でプロジェクトのひな形を作成する。lombok, devtool, web, security, thymeleafあたりを追加する。

plugins {
  id 'java'
  id 'org.springframework.boot' version '3.0.4'
  id 'io.spring.dependency-management' version '1.1.0'
}

group = 'com.example'
version = '0.0.1-SNAPSHOT'
sourceCompatibility = '17'

configurations {
  compileOnly {
    extendsFrom annotationProcessor
  }
}

repositories {
  mavenCentral()
}

dependencies {
  implementation 'org.springframework.boot:spring-boot-starter-security'
  implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'
  implementation 'org.springframework.boot:spring-boot-starter-web'
  implementation 'org.thymeleaf.extras:thymeleaf-extras-springsecurity6'
  compileOnly 'org.projectlombok:lombok'
  developmentOnly 'org.springframework.boot:spring-boot-devtools'
  annotationProcessor 'org.projectlombok:lombok'
  testImplementation 'org.springframework.boot:spring-boot-starter-test'
  testImplementation 'org.springframework.security:spring-security-test'
}

tasks.named('test') {
  useJUnitPlatform()
}

mainを作成する。

@SpringBootApplication
public class SpringSecurityMain {
  public static void main(String[] args) {
    SpringApplication.run(SpringSecurityMain.class, args);
  }
}

spring-bootを起動して http://localhost:8080/ 以下の適当なURLにアクセスすると http://localhost:8080/login にリダイレクトされて以下のようなログイン画面が表示される。

usernameはuser、passwordは起動時に以下のログのようにランダム生成される。

Using generated security password: 1ca8dbe5-ae79-4737-97c3-aead7ec1f2b3

This generated password is for development use only. Your security configuration must be updated before running your application in production.

ログインに成功すると http://localhost:8080/?continue にリダイレクトする。デフォルトだと画面が無くてエラーになるので、例えば以下のように"/"に対するマッピングsrc/main/resources/templates/top.htmlを用意すればログインと認証正常終了までの動作が確認できる。

@Controller
public class AuthenticationController {

  @GetMapping("/")
  public String success() {
    return "top";
  }
}
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
  <body>
    top
  </body>
</html>

ランダムパスワードの生成ではなく、固定値にするには以下のようにプロパティファイル src/main/resources/application.properties で指定する。以下のようにすると、username:username, password:password になる。

spring.security.user.name username
spring.security.user.password password

これでspring-securityのひな形の動作確認は完了。あとはプロジェクトごとに合わせた各種設定変更をする。

Form認証の設定

認証関連URLの変更

認証関連のURLなどの設定変更を行う。

設定クラスを作成する。

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.web.SecurityFilterChain;

@Configuration
@EnableWebSecurity // 1
public class SecurityConfig {

  @Bean
  public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception{
    http.formLogin(login -> login // 2
        .loginProcessingUrl("/authenticate") // 3
        .loginPage("/login") // 4
        .defaultSuccessUrl("/") // 5
        .failureUrl("/login?failed") // 6 
        .permitAll() // 7
    ).authorizeHttpRequests(authz -> authz
        .anyRequest().authenticated()
    );
    return http.build();
  }
}
  1. 設定クラスに @Configuration@EnableWebSecurity *2を付与する。
  2. フォーム認証設定を有効化。
  3. フォーム認証のPOST先URL。ログイン画面のformのPOST先はこのURLになる。
  4. ログイン画面のURL。
  5. 認証成功時のリダイレクト先URL。
  6. 認証失敗時のリダイレクト先URL。この設定の場合?failedを見て各種表示分けなどをする。
  7. failureUrl(String), getLoginPage(), getLoginProcessingUrl()のURLは未認証でもアクセス可能にする。

次に、ログイン画面と認証成功後の画面表示用のハンドラーを作成する。

@Controller
public class AuthenticationController {

  @GetMapping("/login")
  public String login() {
    return "login";
  }
  
  @GetMapping("/")
  public String top() {
    return "top";
  }
}

ログイン画面 src/main/resources/templates/login.html を作成する。公式ドキュメント のサンプルコードをコピペして先述の SecurityConfig の設定と合うよう修正をしている。

<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:th="https://www.thymeleaf.org">
  <head>
    <title>Please Log In</title>
  </head>
  <body>
    <h1>Please Log In</h1>
    <div th:if="${param.failed}">
      Invalid username and password.</div>
    <div th:if="${param.logout}">
      You have been logged out.</div>
    <form th:action="@{/authenticate}" method="post">
      <div>
      <input type="text" name="username" placeholder="Username"/>
      </div>
      <div>
      <input type="password" name="password" placeholder="Password"/>
      </div>
      <input type="submit" value="Log in" />
    </form>
  </body>
</html>

loginProcessingUrl に対してusernameとpasswordをPOSTするフォームを作成する。厳密にはCSRFトークンの送信も必要だがThymeleafのformを使う分には気にする必要が無いのでここでは割愛する。

認証成功時の画面 src/main/resources/templates/top.html を作成する。

<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
  <body>
    top
  </body>
</html>

http://localhost:8080/login でログイン画面が表示されて正常に認証されてtop.htmlが表示されればOK。

フォーム認証のパラメータ名を変更

ログイン画面のform要素のIDとパスワードのパラメータ名はデフォルトではそれぞれusernamepasswordなのでこれを変更するには以下にする。

    http.formLogin(login -> login
        .usernameParameter("i")
        .passwordParameter("p")
        // 省略

ログアウト

デフォルトでは/logout にPOSTするフォームによりログアウトできる。CSRFトークンについてはログインフォームと事情は同様なのでそれについては割愛。

<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
  <body>
    top
    <form th:action="@{/logout}" method="post">
      <input type="submit" value="log out">
    </form>
  </body>
</html>

ログアウトのURLの変更などは以下で行う。以下は代表的な設定例。

    http.formLogin(login -> login
      // 省略
    ).logout(logout -> logout
        .logoutUrl("/custom-logout") // 1
        .logoutSuccessUrl("/login?logout-success") // 2
        .permitAll() // 3
        .invalidateHttpSession(true) // 4 
        .deleteCookies("sample.cookie") // 5
    ) ...
  1. ログアウトのPOST先URL。
  2. ログアウト成功時のリダイレクト先URL。
  3. logoutSuccessUrllogoutUrl のURLは未認証でもアクセス可能にする*3
  4. trueにするとログアウト時に HttpSession.invalidate() を実行する。
  5. ログアウト成功時に削除したいcookie名を指定。

更に細かい制御をしたい場合は logoutSuccessHandler を指定する。これを指定するとlogoutSuccessUrlが無視される点に注意。

    ).logout(logout -> logout
        // 省略
        .logoutSuccessHandler((request, response, authentication) -> System.out.println(authentication))

リダイレクトのdefaultSuccessUrlとフォワードのsuccessForwardUrl

タイトルままだが、認証成功後にリダイレクトするかフォワードするかの違いがある。両者の違いの詳細は省略するがログイン成功後にブラウザに表示されるURLが異なる。

設定 URL
.defaultSuccessUrl("/") /
.successForwardUrl("/") /authenticate

successForwardUrlの場合、成功後の遷移先は以下のように"/"をGETでなくPOSTの必要がある。/authenticateへのPOSTを"/"フォワードするため。

@Controller
public class AuthenticationController {
  // 省略
  @PostMapping("/")
  public String topPost() {
    return "top";
  }
}

認証方法

デフォルトのForm認証アルゴリズムを簡略化して説明すると、何等かのデータソースからユーザIDを基にユーザ情報を取得してエンコードしたパスワードと比較する、となる。認証方法の変更は基本的にはこれの必要箇所を修正という形になる。ドキュメントのDaoAuthenticationProviderに動作概要の解説があるのでこちらも参照。

パスワードのエンコーダーの変更

本番環境ではパスワードは何等かのエンコードがされているため、それにマッチするパスワードのエンコーダーの設定が事実上必須となる。このためにはPasswordEncoderのbeanを作成する。以下例はformから送信されたパスワードをbcryptでエンコードして比較する。

import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;

@Configuration
@EnableWebSecurity
public class SecurityConfig {

  @Bean
  public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception{
    // 省略
  }
  
  @Bean
  public PasswordEncoder passwordEncoder() {
    return new BCryptPasswordEncoder();
  }
}

動作確認用にはsrc/main/resources/application.propertiesのパスワードもbcryptでエンコードしたものに変更の必要がある。以下のパスワードはpエンコードしたもの。

spring.security.user.name username
spring.security.user.password $2a$10$KkGPejeQX5gAjzWZZvITZOuNF1Fsjz3jnl8UiOTz3NBCh6E1nLo1C

デフォルトのパスワードエンコーダー

デフォルトでは複数アルゴリズムに対応可能な設定がされたDelegatingPasswordEncoderが使われる。 このクラスの役割はドキュメント に書かれている。

ユーザデータの取得方法設定

基本的にはパスワードはDBなど何等かのデータソースに格納されているので、そこからリクエストのユーザIDを基にユーザ情報を取得する設定、は基本的には必須の設定となる。これにはUserDetailsServiceのbeanを作成する。

その設定のためのインタフェースは下記で、ユーザIDからユーザ情報を取得する、を実装すれば良い事が分かる。

public interface UserDetailsService {
    UserDetails loadUserByUsername(String username) throws UsernameNotFoundException;
}

bean定義のイメージは以下の通り。

@Configuration
@EnableWebSecurity
public class SecurityConfig {
  // 省略
  @Bean
  public UserDetailsService userDetailsService() {
     return new ...

ちなみにUserDetailsServiceは下記のようにHttpSecurity#userDetailsServiceでも設定可能だが、基本的にはbean定義が良いと思われる。その理由はUserDetailsServiceAutoConfigurationのauto-configが有効になり不要なInMemoryUserDetailsManagerのbeanが生成されてしまうため。

  @Bean
  public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception{
    UserDetailsService userDetailsService = ...
    
    http.formLogin(login -> login
      // 省略
    ).userDetailsService(userDetailsService);
    return http.build();

インメモリ

挙動確認のためにインメモリにユーザデータを持つ設定を作成する。これはパスワードをソースコードにベタ書きとなるため、基本的にはサンプル用で本番環境で使うことは稀と思われる。

以下の設定例はインメモリにuser, adminの2ユーザを持つ。

  @Bean
  public UserDetailsService userDetailsService() {
    UserDetails user = new User("user", "$2a$10$KkGPejeQX5gAjzWZZvITZOuNF1Fsjz3jnl8UiOTz3NBCh6E1nLo1C", List.of()); // p
    UserDetails admin = new User("admin", "$2a$10$PYUaLLme465KrFYJw/fcjuihtzLPviwors8/9XGtaQInEFlvJwaaC", List.of()); // admin
    return new InMemoryUserDetailsManager(user, admin);
  }
  
  @Bean
  public PasswordEncoder passwordEncoder() {
    return new BCryptPasswordEncoder();
  }
}

JDBC

RDBにユーザデータを持つ設定について。例としてmysqlを前提に進める。

まず、JDBCJDBCドライバの依存性を追加する。

implementation 'org.springframework.boot:spring-boot-starter-jdbc'
runtimeOnly 'com.mysql:mysql-connector-j'

ユーザ・認可を保持するテーブルを作成する。JdbcDaoImplDDLのパスがある*4のでjar内の該当パスのファイルを使用する。

今回はmysqlを使用するので型を修正して以下のDDLを実行する。

create table users( 
    username varchar(50) not null primary key
    , password varchar(500) not null
    , enabled boolean not null
); 

create table authorities( 
    username varchar(50) not null
    , authority varchar(50) not null
    , constraint fk_authorities_users foreign key (username) references users(username)
); 

create unique index ix_auth_username 
    on authorities(username, authority);

動作確認用の適当なユーザ・認可情報も追加しておく。デフォルトでは認可が一行も無いユーザは存在しないと見なされる。

insert into users(username, password, enabled) values ('user', '$2a$10$KkGPejeQX5gAjzWZZvITZOuNF1Fsjz3jnl8UiOTz3NBCh6E1nLo1C', true); --p
insert into users(username, password, enabled) values ('admin', '$2a$10$PYUaLLme465KrFYJw/fcjuihtzLPviwors8/9XGtaQInEFlvJwaaC', true); --admin

insert into authorities(username, authority) values ('user', 'USER');
insert into authorities(username, authority) values ('admin', 'ADMIN');

src/main/resources/application.propertiesにDB接続情報を追加する。

spring.datasource.url jdbc:mysql://localhost:13306/mysql
spring.datasource.username root
spring.datasource.password password

UserDetailsServiceの実装クラスの一つであるJdbcDaoImplのbean定義を作成する。

import org.springframework.security.core.userdetails.jdbc.JdbcDaoImpl;

@Configuration
@EnableWebSecurity
public class SecurityConfig {

  @Bean
  public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
    // 省略
  }

  @Bean
  public UserDetailsService userDetailsService(DataSource ds) {
    JdbcDaoImpl jdbcService = new JdbcDaoImpl();
    jdbcService.setDataSource(ds);
    return jdbcService;
  }

  @Bean
  public PasswordEncoder passwordEncoder() {
    return new BCryptPasswordEncoder();
  }
}

これで起動してuser, pでログインできればOK。

別のテーブルを使用

既存テーブルを使用するにはユーザ・認可情報の取得SQL変更により行う。ここでは単にテーブル名をusers から my_usersに変更しただけの例を示す。ユーザのSQLはユーザ・パスワード・有効フラグの順にカラムを返し、認可は2番目に認可を返すSQLになってさえいればよい。

  @Bean
  public UserDetailsService userDetailsService(DataSource ds) {
    JdbcDaoImpl jdbcService = new JdbcDaoImpl();
    jdbcService.setDataSource(ds);
    jdbcService.setUsersByUsernameQuery("""
        select
            username
            , password
            , enabled
        from
            my_users
        where
            username = ?
                """);
    jdbcService.setAuthoritiesByUsernameQuery("""
        select
            username
            , authority
        from
            my_authorities
        where
            username = ?
                """);
    return jdbcService;
  }

独自実装

spring-securityの組み込みクラスで仕様が満たせないなら独自実装となる。たとえば、以下は何等かのユーザ情報取得APIがあると仮定してそれを使用する例。

  @Autowired
  UserApi api = new UserApi();
  
  @Bean
  public UserDetailsService userDetailsService() {
    return username -> 
      api
        .get(username)
        .orElseThrow(() -> new UsernameNotFoundException(""));
  }

nullのreturnは禁止でユーザが見つからない場合はUsernameNotFoundExceptionをスローする。

その他

contollerで認証情報を参照

いくつか方法があるので状況に応じて使用する。

  • 基本的にはAuthenticationを引数に指定して適当な実装クラスにキャストする。ただしanonymousつまり未認証だとnullになる。
  • @CurrentSecurityContextドキュメントを参照。こちらはanonymousでもnullを返さない。
  • SecurityContextHolder.getContext()ドキュメントなどを参照。

ドキュメントにサンプルコード

@Controller
public class SampleController {
  @GetMapping("/foo")
  public String foo(Authentication auth) {
  }

  @GetMapping("/hoge")
  public String hoge(@CurrentSecurityContext SecurityContext context) {
    Authentication auth = context.getAuthentication();
    return "hoge";
  }

  @GetMapping("/bar")
  public String hoge(@CurrentSecurityContext SecurityContext context) {
    Authentication auth = SecurityContextHolder.getContext().getAuthentication();
    return "bar";
  }
}

ハマった点

ERR_TOO_MANY_REDIRECTS

設定内容によるが基本的にはpermitAllが無いため。無い状態で http://localhost:8080/login にアクセスすると、/loginは認証が必要なため/loginにリダイレクトされるのでループしてしまう。

参考URLなど

*1:結構変わったのをきっかけに、Form認証のよくある書き方を一度自分用にまとめようかな、という気分になった。

*2:servletの場合@EnableWebSecurityは無くても動作するが、spring-securityの設定クラスを明示するためにも付与するのが良いと思われる。https://github.com/spring-projects/spring-boot/blob/main/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/security/servlet/SpringBootWebSecurityConfiguration.java#L75 を見るとauto-configで@EnableWebSecurityを自動付与するみたい。

*3:これをしない場合は logoutSuccessUrl が要認証となり、ここから更にログイン画面へのリダイレクトが行われる。なのでブラウザのURL欄にはlogoutSuccessUrlが表示されない。

*4:github上には無いがspring-security-core-x.x.x.jar内にはある

Selenium Gridをdockerで使う

Selenium Gridをdockerコンテナでリモートブラウザを起動するやり方で使用する。

ソースコードと手順

docker-compose.yml

DockerでSelenium Gridを構築してクロスブラウザテストを自動化するを参考にdocker-compose.ymlを作成する。

version: '3.8'

services:
  selenium-hub:
    image: selenium/hub:4.7.2
    ports:
      - 4444:4444
      - 4442:4442
      - 4443:4443

  node-chrome:
    image: selenium/node-chrome:4.7.2
    depends_on:
      - selenium-hub
    environment:
      - TZ=Asia/Tokyo
      - HUB_HOST=selenium-hub
      - SE_EVENT_BUS_HOST=selenium-hub
      - SE_EVENT_BUS_PUBLISH_PORT=4442
      - SE_EVENT_BUS_SUBSCRIBE_PORT=4443

  node-firefox:
    image: selenium/node-firefox:4.7.2
    depends_on:
      - selenium-hub
    environment:
      - TZ=Asia/Tokyo
      - HUB_HOST=selenium-hub
      - SE_EVENT_BUS_HOST=selenium-hub
      - SE_EVENT_BUS_PUBLISH_PORT=4442
      - SE_EVENT_BUS_SUBSCRIBE_PORT=4443
docker-compose up

で起動して http://localhost:4444/ui で下記キャプチャのようなコンソールが開けばOK.

java

javaseleniumのコードを書く。

plugins {
    id 'java'
}

group = 'com.example'
version = '0.0.1-SNAPSHOT'
sourceCompatibility = '17'

repositories {
    mavenCentral()
}

dependencies {
    implementation 'org.seleniumhq.selenium:selenium-java:4.7.2'
}

https://transit.yahoo.co.jp/ にアクセスしてtitle出力するだけのサンプルコード。

import java.net.MalformedURLException;
import java.net.URL;
import java.time.Duration;

import org.openqa.selenium.By;
import org.openqa.selenium.chrome.ChromeOptions;
import org.openqa.selenium.remote.RemoteWebDriver;
import org.openqa.selenium.support.ui.ExpectedConditions;
import org.openqa.selenium.support.ui.WebDriverWait;

public class RemoteSeleniumSample {

  public static void main(String[] args) throws MalformedURLException {
    URL address = new URL("http://localhost:4444");
    ChromeOptions chromeOption = new ChromeOptions();
    chromeOption.setHeadless(true);
    chromeOption.addArguments("--disable-gpu", "--disable-dev-shm-usage");

    RemoteWebDriver driver = new RemoteWebDriver(address, chromeOption);
    try {
      driver.get("https://transit.yahoo.co.jp/");
      WebDriverWait wait = new WebDriverWait(driver, Duration.ofSeconds(10));
      wait.until(ExpectedConditions.presenceOfAllElementsLocatedBy(By.name("from")));
      System.out.println(driver.getTitle());
    } finally {
      driver.quit();
    }
  }
}

複数コンテナ起動

設定で複数ブラウザセッションを起動出来るし、複数コンテナ起動して並列実行も出来る。

 docker-compose up --scale node-chrome=3

javaから3スレッドでseleinumを起動する。

import java.net.MalformedURLException;
import java.net.URL;
import java.time.Duration;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;

import org.openqa.selenium.By;
import org.openqa.selenium.chrome.ChromeOptions;
import org.openqa.selenium.remote.RemoteWebDriver;
import org.openqa.selenium.support.ui.ExpectedConditions;
import org.openqa.selenium.support.ui.WebDriverWait;

public class RemoteSeleniumSample2 {

  public static void main(String[] args) throws MalformedURLException, InterruptedException, ExecutionException {
    ExecutorService es = Executors.newFixedThreadPool(3);
    Future<?> f1 = es.submit(() -> execute());
    Future<?> f2 = es.submit(() -> execute());
    Future<?> f3 = es.submit(() -> execute());

    f1.get();
    f2.get();
    f3.get();

    es.shutdown();
  }

  static void execute() {
    RemoteWebDriver driver = null;
    try {
      URL address = new URL("http://localhost:4444");

      ChromeOptions chromeOption = new ChromeOptions();
      chromeOption.setHeadless(true);
      chromeOption.addArguments("--disable-gpu", "--disable-dev-shm-usage");

      driver = new RemoteWebDriver(address, chromeOption);
      WebDriverWait wait = new WebDriverWait(driver, Duration.ofSeconds(10));

      driver.get("https://transit.yahoo.co.jp/");
      wait.until(ExpectedConditions.presenceOfAllElementsLocatedBy(By.name("from")));

      System.out.println(driver.getTitle());
    } catch (MalformedURLException e) {
      e.printStackTrace();
    } finally {
      driver.quit();
    }
  }

}

コンソール画面は下記のように3個のchromeブラウザ用インスタンスが作成される。また、実行中かどうかも分かる。

ハマった点

unknown error: session deleted because of page crash from tab crashed

メモリ省力化設定を入れて「Selenium::WebDriver::Error::UnknownError: unknown error: session deleted because of page crash」が出ないようにする に従って各種オプションを設定する。