SpringSecurityを使ってJDBC認証を実装してみる

## 目的
SpringSecurityを使用して簡単なJDBC認証を実装してみる。

## 環境
MacBookAir M4
Sequoia 15.5
RancherDesktop 1.18.2

## 前提条件
DBとの接続が完了していてSpringBootアプリケーションが起動できる状態になっていること。

## thymeleafを導入してとりあえずHome画面を表示させる
pomに次の依存関係を追加してください。
```pom.xml
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-thymeleaf</artifactId>
</dependency>

```
Home画面に表示するhtmlファイルを用意する。内容は適当で良いが格納先に注意が必要。
`src/main/resources/templates`以下に格納すること。存在しない場合はフォルダを作成してください。

```src/main/resources/templates/index.html
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
    <head>
        <meta charset="UTF-8">
        <meta name="viewport" content="width=device-width, initial-scale=1.0">
        <title>Demo Application</title>
    </head>

    <body>
        <h1>Welcome to the Demo Application</h1>
        <p>This is a simple Thymeleaf template example.</p>
        <footer>
            <p>&copy; 2025 Demo Application</p>
        </footer>
    </body>
</html>
```
コントローラークラスは次のようなクラスを作成する。
```src/main/java/com/example/demo/controller/TempController.java
package com.example.demo.controller;

import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;

@Controller
public class TempController {
    
    @GetMapping("/")
    public String tempHome(){
        return "index";
    }
}
```
[Home](http://localhost:3000)にアクセスして次のように表示されれば成功。

![スクリーンショット 2025-05-29 0.15.36.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/310625/8d32dff7-7307-427b-898a-3ef424fe62c4.png)

## SpringSecurityを導入
pom.xmlに次の依存関係を追加する。

```pom.xml
    <dependency>
		<groupId>org.springframework.boot</groupId>
		<artifactId>spring-boot-starter-security</artifactId>
	</dependency>
```
そのままアプリケーションを起動するとコンソールにパスワードが出力される。これはアプリケーションを起動する度に更新される。
```console
Using generated security password: c05441e3-ae05-4433-b8e0-3fd780fe1ad9

This generated password is for development use only. Your security configuration must be updated before running your application in production.
```
起動後SpringBootデフォルトの認証画面が表示されるので固定のusernameである`user`と出力されたパスワードを入力するとログインに成功する。このときコンソールにはSQLのログなどは出力されていない。

![スクリーンショット 2025-05-30 0.00.25.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/310625/24b6e610-04c2-48f8-a8b3-374d55f8db60.png)
ログインに成功するとHome画面が表示される。

:::note info
特に設定をしていなくとも認証処理が動作している？
これはSpringInitializrからプロジェクトを作成したときmainメソッドのあるクラスの`@SpringBootApplication`の働きによるものである。
`@EnableAutoConfiguration`は一つのアノテーションの中に複数のアノテーションがまとめられている。この中の`@EnableAutoConfiguration`が動作することにより自動的にデフォルトの認証設定が適用されている。
`org.springframework.boot.autoconfigure.AutoConfiguration.imports`
:::


## 認証に使用するユーザー情報を定義する
ユーザー情報をDBに持つJDBC（Java Database Connectivity）認証を実装する。

JPAを使用してユーザー情報を取得するのでJPAのライブラリを設定する。今回一部のコードの生成にlombokを使用するためlombokも追加。
```pom.xml
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <path>org.springframework.boot</path>
        <artifactId>spring-boot-starter-data-jpa</artifactId>
    </dependency>
    <dependency>
		<groupId>org.projectlombok</groupId>
		<artifactId>lombok</artifactId>
		<scope>provided</scope>
	</dependency>
```

SpringSecurityの[デフォルトのuserスキーマ](https://github.com/spring-projects/spring-security/blob/main/core/src/main/resources/org/springframework/security/core/userdetails/jdbc/users.ddl)では権限は別テーブルで設定しているが今回は簡略化のため同じテーブルで扱う。
ユーザーのエンティティは次のように定義。
```src/main/java/com/example/demo/entity/AppUser.java
@Entity
@Table(name = "app_users")
@Getter
@NoArgsConstructor
@AllArgsConstructor
public class AppUser extends User {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(unique = true, nullable = false)
    private String username;

    @Column(unique = true, nullable = false)
    private String email;

    @Column(nullable = false)
    private String password;

    private UserType userType;

    private Long createdBy;

    private LocalDateTime createdAt;

    private Long UpdatedBy;

    private LocalDateTime updatedAt;

    private Long deletedBy;

    private LocalDateTime deletedAt;
}
```
ロールをuserTypeとする。今回UserTypeという名のEnumにしている。
```src/main/java/com/example/demo/enums/UserType.java
public enum UserType {
    ADMIN(1, "ADMIN"), GENERAL(2, "GENERAL");

    private final int code;
    private final String label;

    UserType(int code, String label) {
        this.code = code;
        this.label = label;
    }

    @Override
    public String toString() {
        return switch (this) {
            case ADMIN -> "ADMIN";
            case GENERAL -> "GENERAL";
            default -> null;
        };
    }
}
```

合わせて対応するDDLをmigrationに追加。これでアプリケーション起動時に自動でテーブルが作成される。

```src/main/resources/db/migration/V0.02__create_app_users_table.sql
--  V0.02__Create_app_users_table.sql
CREATE TABLE app_users
(
    id         BIGINT AUTO_INCREMENT NOT NULL,
    username   VARCHAR(255) NOT NULL,
    email      VARCHAR(255) NOT NULL,
    password   VARCHAR(255) NOT NULL,
    user_type  SMALLINT NULL,
    created_by BIGINT NULL,
    created_at datetime NULL,
    updated_by BIGINT NULL,
    updated_at datetime NULL,
    deleted_by BIGINT NULL,
    deleted_at datetime NULL,
    CONSTRAINT pk_app_users PRIMARY KEY (id)
);

ALTER TABLE app_users
    ADD CONSTRAINT uc_app_users_email UNIQUE (email);

ALTER TABLE app_users
    ADD CONSTRAINT uc_app_users_username UNIQUE (username);
```
JPAを使用してDBからユーザー情報を取得するためRepositoryも用意する。
今回取得時にuserNameを指定する必要がある。findByUsernameメソッドを追加する。

```src/main/java/com/example/demo/repository/AppUserRepository.java
@Repository
public interface AppUserRepository extends JpaRepository<AppUser, Long> {

    Optional<AppUser> findByUsername(String username);

}
```

## Spring Securityの設定

認証に使用するユーザーオブジェクトを定義。このオブジェクトのログイン中のユーザー情報などは認証後のAuthenticationに格納されて間接的に取得できる。

```src/main/java/com/example/demo/security/AppUserDetails.java
public class AppUserDetails implements UserDetails{

    private final AppUser appUser;

    public AppUserDetails(AppUser appUser) {
        this.appUser = appUser;
    }

    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        return List.of(() -> appUser.getUserType().toString());
    }

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

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

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

    @Override
    public boolean isAccountNonLocked() {
        return true;
    }
    
    @Override
    public boolean isCredentialsNonExpired() {
        return true;
    }

}
```
次に認証対象を取得する役割を持つUserDetailsServiceというクラスを用意する。
認証は行っておらずusernameを指定して対象ユーザーを取得。先のAppUserDetailsを返却しているだけである。

```src/main/java/com/example/demo/service/AppUserDetailsService.java
@Service
public class AppUserDetailsService implements UserDetailsService {

    private final AppUserRepository appUserRepository;

    public AppUserDetailsService(AppUserRepository appUserRepository) {
        this.appUserRepository = appUserRepository;
    }

    @Override
    public AppUserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        AppUser appUser = appUserRepository.findByUsername(username)
                .orElseThrow(() -> new UsernameNotFoundException("User not found with username: " + username));

        return new AppUserDetails(appUser);
    }
}
```
やっとSecurityConfigでSpringSecurityをカスタマイズ。
前述の通り@SpringBootApplicationのアノテーションによりデフォルトの設定がいくつかされているのでここでは二つのBeanの追加だけを行う。

:::note info
SecurityFilterの設定やAuthenticationSuccessEventなどのアプリケーション全体に対してのセキュリティの設定を行いたいといった場合はこのクラスにBeanを追加していく必要がある。
:::
- passwordEncoder()
一つ目のBeanは今回パスワードのハッシュ化で使うエンコード方式を設定している。BCryptを使用。
- authenticationProvider()
returnで返しているproviderが今回実際に認証を行うクラスになる。
一番目のセッターで今回のパスワードのハッシュ化に使用した方式をセット。二番目のセッターでユーザー情報の取得クラスであるAppUserDetailsServiceを指定することにより、認証を行うproviderに対象ユーザーを受け渡すことができる。

```src/main/java/com/example/demo/config/SecurityConfig.java
@EnableWebSecurity
@Configuration
public class SecurityConfig {

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

    @Bean
    DaoAuthenticationProvider authenticationProvider(AppUserDetailsService appUserDetailsService) {
        DaoAuthenticationProvider provider = new DaoAuthenticationProvider();
        provider.setPasswordEncoder(passwordEncoder());
        provider.setUserDetailsService(appUserDetailsService);
        return provider;
    }
}
```

## 認証用ユーザーの作成

二つのテストユーザーを今回は用意する。
用意するにあたって問題となるのはハッシュ化されたパスワードをどう生成するかという点だと思う。
```src/main/resources/db/dev/V1.98__insert_app_users_data.sql
-- V1.98__Insert_app_users_data.sql
-- 管理者ユーザー登録（GENERAL: code = 1）
INSERT INTO app_users (username,
                       email,
                       password,
                       user_type,
                       created_by,
                       created_at)
VALUES ('test_user1',
        'test1@example.com',
        '$2a$10$TgZKACDrkbKXD7LdnGL0MO81Zp/Qye6neOYzqy7/VoxsiXhdi8mNC', -- 「Password」のBCryptハッシュ
        1, -- ADMIN
        1,
        CURRENT_TIMESTAMP);


-- 一般ユーザー登録（GENERAL: code = 2）
INSERT INTO app_users (username,
                       email,
                       password,
                       user_type,
                       created_by,
                       created_at)
VALUES ('test_user2',
        'test2@example.com',
        '$2a$10$TgZKACDrkbKXD7LdnGL0MO81Zp/Qye6neOYzqy7/VoxsiXhdi8mNC', -- 「Password」のBCryptハッシュ
        2, -- GENERAL
        1,
        CURRENT_TIMESTAMP);
```
簡単にこのハッシュ化されたパスワードを用意するには次のようなソースをmainメソッドのあるクラスに一時的に配置する。

```diff_java
@SpringBootApplication
public class DemoApplication {

	public static void main(String[] args) {
		SpringApplication.run(DemoApplication.class, args);

+		BCryptPasswordEncoder passwordEncoder = new   
+       BCryptPasswordEncoder();

+		String rawPassword = "Password"; // エンコードしたい平文のパスワード
+		String encodedPassword = passwordEncoder.encode(rawPassword);
+		System.out.println(encodedPassword);
	}
}
```
このソースを配置してアプリケーションを起動するとコンソールに次のように出力される。
ハッシュ結果は生成毎に異なる。
```console
2025-06-17T23:42:11.208+09:00  INFO 66978 --- [demo] [  restartedMain] o.s.b.w.embedded.tomcat.TomcatWebServer  : Tomcat started on port 3000 (http) with context path '/'
2025-06-17T23:42:11.214+09:00  INFO 66978 --- [demo] [  restartedMain] com.example.demo.DemoApplication         : Started DemoApplication in 2.379 seconds (process running for 2.642)
$2a$10$qEiyCfr1SzA8um5VGa6E0u0ZWe2M4vCtfMOSW.a/TbozVYK8VO4lu
2025-06-17T23:42:11.682+09:00  INFO 66978 --- [demo] [on(3)-127.0.0.1] o.a.c.c.C.[Tomcat].[localhost].[/]       : Initializing Spring DispatcherServlet 'dispatcherServlet'
2025-06-17T23:42:11.683+09:00  INFO 66978 --- [demo] [on(3)-127.0.0.1] o.s.web.servlet.DispatcherServlet        : Initializing Servlet 'dispatcherServlet'
2025-06-17T23:42:11.684+09:00  INFO 66978 --- [demo] [on(3)-127.0.0.1] o.s.web.servlet.DispatcherServlet   
```
$マークで始まっている行がハッシュ化されたパスワードであるのでこれをmigrationファイルに使用する。

## ログイン実行
ログイン画面、ログイン後に表示する画面は特に変化はないが実際にログインすると次のようなログが出力されていることが確認でき、JDBC認証が有効になっていることが確認できる。
```console
2025-06-17T23:56:43.336+09:00 DEBUG 66978 --- [demo] [nio-3000-exec-1] org.hibernate.SQL                        : select au1_0.id,au1_0.updated_by,au1_0.created_at,au1_0.created_by,au1_0.deleted_at,au1_0.deleted_by,au1_0.email,au1_0.password,au1_0.updated_at,au1_0.user_type,au1_0.username from app_users au1_0 where au1_0.username=?
Hibernate: select au1_0.id,au1_0.updated_by,au1_0.created_at,au1_0.created_by,au1_0.deleted_at,au1_0.deleted_by,au1_0.email,au1_0.password,au1_0.updated_at,au1_0.user_type,au1_0.username from app_users au1_0 where au1_0.username=?
2025-06-17T23:56:43.344+09:00 TRACE 66978 --- [demo] [nio-3000-exec-1] org.hibernate.orm.jdbc.bind              : binding parameter (1:VARCHAR) <- [test_user1]

```

## 参考

https://spring.pleiades.io/spring-security/reference/servlet/getting-started.html

https://spring.io/projects/spring-security