SpringSecurity+BCrypt+PostgreSQL でログイン機能実装

## はじめに
必要最低限のログイン機能のみを実装しています。ソースファイルは[github](https://github.com/campane11a/Springboot-Login-Qiita)に載せています。

## 環境構築
[pleiades](https://willbrains.jp/)からpleiades All in One EclipseのJavaを使ってインストールしました。Java開発に必要なものをまとめてインストールできてとても便利なのでおすすめです。

## 開発環境
* Eclipse
* Java17
* Maven
* Spring Tools
* Spring boot
* Spring Security
* PostgreSQL 16

## データベース作成
PostgreSQLで以下のカラムを持つテーブルを作成します。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1462637/fe9af0c0-6ade-1c86-07e0-516b03258ef6.png)

pgAdmin4でQuery Toolを開きます。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1462637/051c69df-9ce2-0b51-7381-5df59a77bdae.png)

下記を入力して▶を押すとテーブルが作成できます。テーブル名xxxxは好きなものに変えてください。今回のソースコードではtestにしています。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1462637/1d677ce6-a5d5-4f93-b758-d5608ba459b3.png)

下記のユーザーをテーブルに登録しておきます。
|id|password|
|:---|:---|
|user|`$2a$10$cFLRib1xp1u9JqeWuNawbe0NpLaTQP4/Klx6.QCEhNE7D/RryTRC.`|

```
INSERT INTO xxxx (id, password) 
VALUES ('user', '$2a$10$cFLRib1xp1u9JqeWuNawbe0NpLaTQP4/Klx6.QCEhNE7D/RryTRC.');
```

## 新規プロジェクト作成

左上 ファイル(F)>新規(N)>Springスターター・プロジェクト(Spring Initializr)を開き、下記の設定で[次へ]をクリック。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1462637/12f5e81c-de62-f06a-86ba-cf34d484a08d.png)

下画像の依存関係にチェックを入れて[完了]をクリック。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1462637/4e3035a3-8fa5-7756-5750-f04f422da3dc.png)

## プロジェクトの実行
pom.xmlを開き、下記を追加します。
```
		<dependency>
			<groupId>org.postgresql</groupId>
			<artifactId>postgresql</artifactId>
			<scope>runtime</scope>
		</dependency>
```
次に、application.propertiesに内容を追加します。
上4行はPostgreSQLに接続するための設定です。
パスワードは自分で設定したものに変更します。
それ以外はデフォルトであれば下記のように設定されています。
```application.properties
spring.datasource.url=jdbc:postgresql://localhost:5432/postgres
spring.datasource.username=postgres
spring.datasource.password=postgresのパスワード
spring.datasource.driver-class-name=org.postgresql.Driver

spring.jpa.hibernate.ddl-auto=update
spring.jpa.show-sql=true
```

次に、プロジェクトを右クリック>実行>5 Maven Installをクリックして実行します。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1462637/29558bae-7edf-3e1f-2911-e353e2e357c2.png)

コンソールで下記のようにBUILD SUCCESSと表示されれば成功です。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1462637/34416063-45ba-4a96-914a-f7a6f5f9f711.png)

次に、プロジェクトを右クリック>実行>9 Spring Bootアプリケーションをクリックして実行します。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1462637/cdf5ec6f-435a-df19-7d58-ee7ccbc8917b.png)

http://localhost:8080/ にアクセスするとデフォルトのログイン画面が表示されます。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1462637/529063f4-da31-a62f-bf3d-fa3a08aabecf.png)
今後認証の設定を行うことでログインする必要はなくなりますが、[こちらの記事](https://qiita.com/campane11a/items/901b262d8ce44bfa20ff)にUsernameとPasswordの設定方法を記載しています。

## ログイン機能の実装

ここから、ログイン機能の実装をしていきます。

### プロジェクト構成
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1462637/77130f18-6007-956f-855f-46c4c42431b4.png)
プロジェクトには、デフォルトでフォルダが作成されていますが、フォルダごとに格納するファイルが決まっています。以下のサイトが参考になります。
https://qiita.com/aaaaaayyymmm/items/f5458d2302c11202136d

### View
トップページ
```index.html
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Index</title>
</head>
<body>
   <header>
	   会員登録
	   <a href="/loginPage">ログイン</a>
   </header>
   <h1>Index</h1>
</body>
</html>
```

ログイン用ページ
```login.html
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org"
      xmlns:sec="http://www.thymeleaf.org/extras/spring-security">
<head>
    <meta charset="UTF-8">
    <title>ログイン</title>
</head>
<body>
    <h1>ログイン</h1>
    <div th:if="${param.error}" > <!-- リクエストパラメータの判定 -->
        <div>
          UserNameまたは、Passwordが違います。 <!-- 認証エラーメッセージ -->
        </div>
    </div>

    <form method="post" th:action="@{/authenticate}"> <!-- ログイン処理のパスを指定  -->
        <label>UserName:</label>
        <input type="text" id="userName" name="userName"> <!-- ユーザ名の入力部 -->
        <br>
        <label>Password:</label>
        <input type="password" id="password" name="password"> <!-- パスワードの入力部 -->
        <br>
        <input type="submit" id="submit" value="ログイン">
    </form>
</body>
</html>

```
ログイン後ページ
```home.html
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org"
      xmlns:sec="http://www.thymeleaf.org/extras/spring-security">
<head>
    <meta charset="UTF-8">
    <title>Home</title>
</head>
<body>
    <h1>Home</h1>



    <form method="post" th:action="@{/logout}">
        <input type="submit" value="ログアウト">
    </form>
</body>
</html>
```

### Controller
Mapping情報を受け取ってページを表示します。
```IndexController.java
package com.bootsample.controller;

import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;

@Controller
public class IndexController {

    @RequestMapping(value = "/", method = RequestMethod.GET)
    public String helloWorld(Model model) {
        return "index";
    }
}
```
```LoginController.java
package com.bootsample.controller;

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

@Controller
public class LoginController {
    @GetMapping("/loginPage")
    String loginPage() {
        return "login";
    }
}
```
```HomeController.java
package com.bootsample.controller;

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

@Controller
public class HomeController {
    @GetMapping("/home")
    public String home() {
        return "home";
    }
}
```

### Config
```SecurityConfig.java
package com.bootsample.config;

//import org.springframework.boot.autoconfigure.security.servlet.PathRequest;
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.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;

@Configuration
@EnableWebSecurity
public class SecurityConfig {
    
    @Bean
    protected SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        // 認可の設定
        http.authorizeHttpRequests(authz -> authz
        		.requestMatchers("/").permitAll()           //ページは全ユーザからのアクセスを許可
                .requestMatchers("/loginPage").permitAll()  //loginPageは、全ユーザからのアクセスを許可
                .anyRequest().authenticated()               //上記ページ以外は認証を求める

                );
        // ログイン設定
        http.formLogin(login -> login                   //フォーム認証の有効化
                .loginPage("/loginPage")                //ログインフォームを表示するパス
                .loginProcessingUrl("/authenticate")    //フォーム認証処理のパス
                .usernameParameter("userName")          //ユーザ名のリクエストパラメータ名
                .passwordParameter("password")          //パスワードのリクエストパラメータ名
                .defaultSuccessUrl("/home")             //認証成功時に遷移するデフォルトのパス
                .failureUrl("/loginPage?error=true")    //認証失敗時に遷移するパス
        		);
        // ログアウト設定
        http.logout(logout -> logout
                .logoutSuccessUrl("/loginPage")         //ログアウト成功時に遷移するパス
                .permitAll()                            //全ユーザに対してアクセスを許可
                );
        return http.build();
    }
	
	@Bean
	protected PasswordEncoder passwordEncoder() {
		return new BCryptPasswordEncoder();
	}
}
```
### Entity
```MyUser.java
package com.bootsample.entity;

import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import lombok.Getter;
import lombok.Setter;

@Getter
@Setter
@Entity
@Table(name = "test")
public class MyUser {
	@Id
	private String id;
	private String password;
}
```
### Repository
```UserDao.java
package com.bootsample.repository;

import org.springframework.stereotype.Repository;

import com.bootsample.entity.MyUser;

@Repository
public interface UserDao {
    MyUser findUserByUserName(String userName);
}
```
```UserDaoImpl.java
package com.bootsample.repository;

import java.util.Map;

import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Repository;

import com.bootsample.entity.MyUser;

@Repository
public class UserDaoImpl implements UserDao {
    private final JdbcTemplate jdbcTemplate;

    public UserDaoImpl(JdbcTemplate jdbcTemplate) {
        this.jdbcTemplate = jdbcTemplate;
    }

    /**
     * userNameを検索条件にSELECT文を実行して、DBに登録されているユーザを検索する
     * @param userName
     * @return User
     */
    @Override
    public MyUser findUserByUserName(String userName) {
        String sql = "SELECT id, password FROM test WHERE id = ?";

        //ユーザを一件取得
        Map<String, Object> result = jdbcTemplate.queryForMap(sql, userName);

        // Entityクラス(User型)に変換
        MyUser user = convMapToUser(result);

        return user;
    }

    /**
     * SQL SELECT文を実行した結果(Map<String, Object>)をUser型に変換する
     * @param Map<String, Object>
     * @return User
     */
    private MyUser convMapToUser(Map<String, Object> map) {
        MyUser user = new MyUser();

        user.setId((String) map.get("username"));
        user.setPassword((String) map.get("password"));

        return user;
    }
}
```
### Service
```AccountUserDetails.java
package com.bootsample.service;

import java.util.Collection;

import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.AuthorityUtils;
import org.springframework.security.core.userdetails.UserDetails;

import com.bootsample.entity.MyUser;

public class AccountUserDetails implements UserDetails {
	private final MyUser myUser;

    public AccountUserDetails(MyUser myUser) {
        this.myUser = myUser;
    }

    public MyUser getUser() { // --- (1) Entityである MyUserを返却するメソッド
        return myUser;
    }

    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() { // --- (3) ユーザに与えられている権限リストを返却するメソッド
        return AuthorityUtils.createAuthorityList("ROLE_USER");
    }

    @Override
    public String getPassword() { // --- (4) 登録されているパスワードを返却するメソッド
        return this.myUser.getPassword();
    }

    @Override
    public String getUsername() { // --- (5) ユーザ名を返却するメソッド
        return this.myUser.getId();
    }

    @Override
    public boolean isAccountNonExpired() { // --- (6) アカウントの有効期限の状態を判定するメソッド
        return true;
    }

    @Override
    public boolean isAccountNonLocked() { // --- (7) アカウントのロック状態を判定するメソッド
        return true;
    }

    @Override
    public boolean isCredentialsNonExpired() { // --- (8) 資格情報の有効期限の状態を判定するメソッド
        return true;
    }

    @Override
    public boolean isEnabled() { // --- (9) 有効なユーザかを判定するメソッド
        return true;
    }
}
```
```AccountUserDetailsService.java
package com.bootsample.service;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.dao.EmptyResultDataAccessException;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;

import com.bootsample.entity.MyUser;
import com.bootsample.repository.UserDaoImpl;

@Service
public class AccountUserDetailsService implements UserDetailsService {

	@Autowired
    private UserDaoImpl userDaoImpl;

	
    public UserDetails loadUserByUsername(String userName)
    	throws UsernameNotFoundException{
    	
    	if(userName == null || "".equals(userName)) {
    		throw new UsernameNotFoundException(userName + "is not found");
    	}
    	
    	try {
    		MyUser myUser = userDaoImpl.findUserByUserName(userName);
    		
            if (myUser != null) {
                return new AccountUserDetails(myUser); // --- (2) UserDetailsの実装クラスを生成

            } else {
                throw new UsernameNotFoundException(userName + "is not found");
            }
    	} catch(EmptyResultDataAccessException e) {
    		throw new UsernameNotFoundException(userName + "is not found");
    	}
        
    }
}
```