Spring Boot 3 サインイン/サインアウト/サインアップのみ行うサンプル

# はじめに
Spring boot3でサインイン/サインアウト/サインアップを行うだけの最小単位の機能を持つプロジェクト作成する。



### 環境
- Java 21
- Spring Framework 6.1.8
- Spring boot 3.3.0
- Spring Security 6.3.0
- thymeleaf 3.1.2
- lombok 1.18.32
- mysql-connector-j 8.3.0
- MySQL 8.4.0



### その他
- サインイン時の認証方法はユーザーパスワード方式(DB認証)とする



# DB



### usersテーブル
``` sql:usersテーブル
CREATE TABLE users (
  id        int          NOT NULL AUTO_INCREMENT,
  user_name varchar(64)  NOT NULL,
  user_id   varchar(64)  NOT NULL,
  password  varchar(256) NOT NULL,
  
  PRIMARY KEY (id),
  UNIQUE KEY UK_01 (user_id)
)
```



# パッケージ(com.example.sample)



### Controller



#### IndexController.java
``` java:IndexController.java
package com.example.sample.controller;

@Controller
public class IndexController {
	@GetMapping("/")
	public String get() {
		return "index";
	}
}
```



#### SignInController.java
``` java:SignInController.java
package com.example.sample.controller;

@Controller
public class SignInController {
	@GetMapping("/signin")
	public String get( SigninForm signinForm ) {
		SecurityContext securityContext = SecurityContextHolder.getContext();
		if(securityContext != null) {
			Authentication authentication = securityContext.getAuthentication();
			if(authentication != null) {
				if( authentication instanceof UsernamePasswordAuthenticationToken) {
					return "redirect:/";
				}
			}
		}
		
		return "signin";
	}

	@GetMapping(value="/signin", params="error")
	public String getError(SigninForm signinForm ) {
		return "signin";
	}

	@GetMapping(value="/signin", params="signout")
	public String getSignout(SigninForm signinForm ) {
		return "signin";
	}
}
```



#### SignOutController.java
``` java:SignOutController.java
package com.example.sample.controller;

@Controller
@RequiredArgsConstructor 
public class SignOutController {
	@GetMapping("/signout")
	public String get() {
		return "signout";
	}
}
```



#### SignUpController.java
``` java:SignUpController.java
package com.example.sample.controller;

@Controller
@RequiredArgsConstructor 
public class SignUpController {
	private final UsersService service;
	
	@GetMapping("/signup")
	public String get(SignupForm signupForm) {
		SecurityContext securityContext = SecurityContextHolder.getContext();
		if(securityContext != null) {
			Authentication authentication = securityContext.getAuthentication();
			if(authentication != null) {
				if( authentication instanceof UsernamePasswordAuthenticationToken) {
					return "redirect:/";
				}
			}
		}
		
		return "signup";
	}
	
	@PostMapping("/signup")
	public String post(Model model, @Valid SignupForm signupForm, BindingResult bindingResult) {
		if(bindingResult.hasErrors()) {
			return "signup";
		}
		
		if( !signupForm.getPassword().equals(signupForm.getPasswordRetype())) {
			model.addAttribute("msg", "パスワードが一致しません。");
			return "signup";
		}
		
		UserEntity userEntity = service.getByUserId(signupForm.getUserId());
		if( userEntity != null ) {
			model.addAttribute("msg", "ユーザーIDはすでに使用されています。");
			return "signup";
		}
		
		userEntity = service.save(signupForm);
		
		return "redirect:/signin";
	}
}
```



### Entity



#### UserEntity.java
``` java:UserEntity.java
package com.example.sample.entity;

@Entity
@Table(name="users")
@Getter
@Setter
public class UserEntity {
	@Id
	@GeneratedValue(strategy = GenerationType.IDENTITY)
	@Column(name="id")
	private Integer id;
	
	@Column(name="user_name")
	private String userName;
	
	@Column(name="user_id")
	private String userId;
	
	@Column(name="password")
	private String password;
}
```



### Form



#### SigninForm.java
``` java:SigninForm.java
package com.example.sample.form;

@Data
public class SigninForm {
	@NotBlank(message="入力必須です")
	@Size(max = 64)
	private String userId;
	
	@NotBlank(message="入力必須です")
	@Size(max = 256)
	private String password;
}
```



#### SignupForm.java
``` java:SignupForm.java
package com.example.sample.form;

@Data
public class SignupForm {
	@NotBlank(message="入力必須です")
	@Size(max = 64)
	private String userName;
	
	@NotBlank(message="入力必須です")
	@Size(max = 64)
	private String userId;
	
	@NotBlank(message="入力必須です")
	@Size(max = 256)
	private String password;
	
	@NotBlank(message="入力必須です")
	@Size(max = 256)
	private String passwordRetype;
}
```



### Repository



#### UsersRepository.java
``` java:UsersRepository.java
package com.example.sample.repository;

public interface UsersRepository extends JpaRepository<UserEntity, Integer> {
	UserEntity findByUserId(String userId);
}
```



### Security



#### SampleSecurityConfig.java
``` java:SampleSecurityConfig.java
package com.example.sample.security;

@Configuration
@EnableWebSecurity
public class SampleSecurityConfig {
	@Bean
	SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
		http.authorizeHttpRequests(authorize -> authorize
				.requestMatchers("/signin", "/signup").permitAll()
				.anyRequest().authenticated()
			);
		
		http.formLogin(login -> login
				.loginPage("/signin")
				.defaultSuccessUrl("/")
				.loginProcessingUrl("/signin")
				.failureUrl("/signin?error")
				.usernameParameter("userId")
				.passwordParameter("password")
			);

		http.logout(logout -> logout
				.logoutUrl("/signout")
				.logoutSuccessUrl("/signin?signout")
			);

		return http.build();
	}
	
	@Bean
	PasswordEncoder passwordEncoder() {
		return PasswordEncoderFactories.createDelegatingPasswordEncoder();
	}
}
```



#### SampleUserDetails.java
``` java:SampleUserDetails.java
package com.example.sample.security;

public class SampleUserDetails implements UserDetails {
	private UserEntity userEntity;
	
	public SampleUserDetails(UserEntity userEntity) {
		this.userEntity = userEntity;
	}
	
	@Override
	public Collection<? extends GrantedAuthority> getAuthorities() {
		return null;
	}

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

	@Override
	public String getUsername() {
		return this.userEntity.getUserId();
	}
 
	public UserEntity getUserEntity() {
		return this.userEntity;
	}
}
```



#### SampleUserDetailsService.java
``` java:SampleUserDetailsService.java
package com.example.sample.security;

@Service
@RequiredArgsConstructor
public class SampleUserDetailsService implements UserDetailsService {
	private final UsersRepository repository;
	
	@Override
	public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
		if( username == null || username.isEmpty() ) {
			throw new UsernameNotFoundException("user not found.");
		}
		
		UserEntity userEntity = repository.findByUserId(username);
		if( userEntity == null) {
			throw new UsernameNotFoundException("user not found.");
		}
		
		return new SampleUserDetails(userEntity);
	}
}
```



### Service



#### UsersService.java
``` java:UsersService.java
package com.example.sample.service;

@Service
@RequiredArgsConstructor
public class UsersService {
	private final UsersRepository repository;
	
	private final PasswordEncoder passwordEncoder;
	
	public UserEntity getByUserId(String userId) {
		return repository.findByUserId(userId);
	}
	
	public UserEntity save(SignupForm signupForm) {
		UserEntity userEntity = new UserEntity();
		userEntity.setUserName(signupForm.getUserName());
		userEntity.setUserId(signupForm.getUserId());
		userEntity.setPassword( passwordEncoder.encode(signupForm.getPassword()));
		
		return repository.saveAndFlush(userEntity);
	}
}
```



# sample/src/main/resources



### templates

#### index.html
``` html:index.html
<!DOCTYPE html>
<html
	xmlns:th="http://www.thymeleaf.org"
	xmlns:sec="https://www.thymeleaf.org/thymeleaf-extras-springsecurity">

<head>
	<meta charset="UTF-8">
	<title>Index</title>
</head>

<body>
	<header>
		<div>
			ログインユーザー名：<span sec:authentication="name"></span>
		</div>
		<div>
			<a th:href="@{/signout}" th:text="サインアウト"></a>
		</div>
	</header>
	
	<main>
		<h1>Index</h1>
	</main>
	
	<footer></footer>
</body>

</html>
```



#### signin.html
``` html:signin.html
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
	
<head>
	<meta charset="UTF-8">
	<title>サインイン</title>
</head>

<body>
	<header></header>
	
	<main>
		<h1>サインイン</h1>
		
		<div th:if="${param.error != null and session.containsKey('SPRING_SECURITY_LAST_EXCEPTION')}">
			<span th:text="${session.SPRING_SECURITY_LAST_EXCEPTION.message}"></span>
		</div>
		
		<form method="post" th:action="@{/signin}" th:object="${signinForm}">
			<div>
				<label>ユーザーID</label>
				<input type="text" th:field="*{userId}">
				<div th:if="${#fields.hasErrors('userId')}" th:errors="*{userId}" ></div>
			</div>
			<div>
				<label>パスワード</label>
				<input type="password" th:field="*{password}">
				<div th:if="${#fields.hasErrors('password')}" th:errors="*{password}" ></div>
			</div>
			<input type="submit" value="サインイン">
		</form>
		
		<div>
			<a th:href="@{/signup}">サインアップへ</a>
		</div>
	</main>
	
	<footer></footer>
</body>

</html>
```



#### signout.html
``` html:signout.html
<!DOCTYPE html>
<html
	xmlns:th="http://www.thymeleaf.org"
	xmlns:sec="https://www.thymeleaf.org/thymeleaf-extras-springsecurity">
	
<head>
	<meta charset="UTF-8">
	<title>サインアウト</title>
</head>
	
<body>
	<header>
		<div>
			ログインユーザー名：<span sec:authentication="name"></span>
		</div>
		<div>
			<a th:href="@{/signout}" th:text="サインアウト"></a>
		</div>
	</header>
	
	<main>
		<h1>サインアウト</h1>
		
		<form method="post" th:action="@{/signout}" >
			<p><button>サインアウト</button></p>
		</form>
	</main>
	
	<footer></footer>
</body>

</html>
```



#### signup.html
``` html:signup.html
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">

<head>
	<meta charset="UTF-8">
	<title>サインアップ</title>
</head>

<body>
	<header></header>
	
	<main>
		<h1>サインアップ</h1>

  		<div th:Text="${msg}"></div>
		
		<form method="post" th:action="@{/signup}" th:object="${signupForm}">
			<div>
				<label>ユーザー名</label>
				<input type="text" th:field="*{userName}">
				<div th:if="${#fields.hasErrors('userName')}" th:errors="*{userName}"></div>
			</div>
			<div>
				<label>ユーザーID</label>
				<input type="text" th:field="*{userId}">
				<div th:if="${#fields.hasErrors('userId')}" th:errors="*{userId}" ></div>
			</div>
			<div>
				<label>パスワード</label>
				<input type="password" th:field="*{password}">
				<div th:if="${#fields.hasErrors('password')}" th:errors="*{password}" ></div>
			</div>
			<div>
				<label>パスワード(確認用)</label>
				<input type="password" th:field="*{passwordRetype}">
				<div th:if="${#fields.hasErrors('passwordRetype')}" th:errors="*{passwordRetype}" ></div>
			</div>
			<input type="submit" value="登録">
		</form>
		
		<div>
			<a th:href="@{/signin}">サインインへ</a>
		</div>
	</main>
	
	<footer></footer>
</body>

</html>
```



### application.properties
``` text:application.properties
#
spring.application.name=sample

#
spring.datasource.url=jdbc:mysql://ホスト名:3306/DB名
spring.datasource.username=ユーザ名
spring.datasource.password=パスワード
spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver

#
spring.jpa.database-platform=org.hibernate.dialect.MySQLDialect
#* spring.jpa.show-sql=true

#
spring.jpa.open-in-view=false
```


### pom.xml
``` xml:pom.xml
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
	xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
	<modelVersion>4.0.0</modelVersion>
	<parent>
		<groupId>org.springframework.boot</groupId>
		<artifactId>spring-boot-starter-parent</artifactId>
		<version>3.3.0</version>
		<relativePath/> <!-- lookup parent from repository -->
	</parent>
	<groupId>com.example</groupId>
	<artifactId>sample</artifactId>
	<version>0.0.1-SNAPSHOT</version>
	<name>sample</name>
	<description>Demo project for Spring Boot</description>
	<properties>
		<java.version>21</java.version>
	</properties>
	<dependencies>
		<dependency>
			<groupId>org.springframework.boot</groupId>
			<artifactId>spring-boot-starter-data-jpa</artifactId>
		</dependency>
		<dependency>
			<groupId>org.springframework.boot</groupId>
			<artifactId>spring-boot-starter-security</artifactId>
		</dependency>
		<dependency>
			<groupId>org.springframework.boot</groupId>
			<artifactId>spring-boot-starter-thymeleaf</artifactId>
		</dependency>
		<dependency>
			<groupId>org.springframework.boot</groupId>
			<artifactId>spring-boot-starter-validation</artifactId>
		</dependency>
		<dependency>
			<groupId>org.springframework.boot</groupId>
			<artifactId>spring-boot-starter-web</artifactId>
		</dependency>
		<dependency>
			<groupId>org.thymeleaf.extras</groupId>
			<artifactId>thymeleaf-extras-springsecurity6</artifactId>
		</dependency>

		<dependency>
			<groupId>org.springframework.boot</groupId>
			<artifactId>spring-boot-devtools</artifactId>
			<scope>runtime</scope>
			<optional>true</optional>
		</dependency>
		<dependency>
			<groupId>com.mysql</groupId>
			<artifactId>mysql-connector-j</artifactId>
			<scope>runtime</scope>
		</dependency>
		<dependency>
			<groupId>org.projectlombok</groupId>
			<artifactId>lombok</artifactId>
			<optional>true</optional>
		</dependency>
		<dependency>
			<groupId>org.springframework.boot</groupId>
			<artifactId>spring-boot-starter-test</artifactId>
			<scope>test</scope>
		</dependency>
		<dependency>
			<groupId>org.springframework.security</groupId>
			<artifactId>spring-security-test</artifactId>
			<scope>test</scope>
		</dependency>
	</dependencies>

	<build>
		<plugins>
			<plugin>
				<groupId>org.springframework.boot</groupId>
				<artifactId>spring-boot-maven-plugin</artifactId>
				<configuration>
					<excludes>
						<exclude>
							<groupId>org.projectlombok</groupId>
							<artifactId>lombok</artifactId>
						</exclude>
					</excludes>
				</configuration>
			</plugin>
		</plugins>
	</build>
</project>
```