はじめに
新卒1年目のエンジニアhirotoです。
前回の記事ではSpring Securityを利用したログイン機能を実装しました。
今回はその続きとして、新規アカウント登録機能を実装します。
登録画面の作成から、入力チェック、パスワードのハッシュ化、DBへの保存までを紹介します。
前提
今回の記事では、前回の記事で実装した内容から流用している箇所(依存関係やデータ関連クラス等)の紹介は一部省略しています。もし最初から実装する場合には、前回の記事を参照してください。
実装
ディレクトリ構成
今回追加・変更するファイルは以下です。
ログイン記事で作成した構成をベースに、
登録画面や登録処理を追加していきます。
project
├─ src
│ └─ main
│ ├─ java
│ │ └─ project
│ │ ├─ entity
│ │ │ └─ Account.java
│ │ ├─ dto
│ │ │ ├─ AccountRegisterForm.java
│ │ │ ├─ AccountRegisterResponse.java
│ │ │ └─ AccountUserDetails.java
│ │ ├─ controller
│ │ │ └─ AccountRegisterController.java
│ │ ├─ service
│ │ │ └─ AccountRegisterService.java
│ │ └─ repository
│ │ └─ AccountRepository.java
│ └─ resources
│ ├─ templates
│ │ └ login
│ │ └─ register.html
│ └─ application.properties
└─ build.gradle
依存関係等の設定
今回はログイン記事で追加したSpring Securityの設定を利用します。
そのため、依存関係やSecurityConfigの設定については前回の記事を参照してください。
新規登録画面を作成する
登録画面では
・ログインID
・パスワード
・確認用パスワード
の3つを入力します。
入力値はThymeleafのフォームオブジェクトへバインドされ、
POST /registerへ送信されます。
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head th:replace="~{common/head :: head_fragment(scripts = ~{::script}, links = ~{::link})}">
</head>
<body>
<div class="container" style="max-width: 500px; margin-top: 50px;">
<h2 class="text-center mb-4">アカウント登録</h2>
<!-- 業務エラーメッセージ表示 -->
<div th:if="${errorMessage}" class="alert alert-danger" role="alert">
<span th:text="${errorMessage}"></span>
</div>
<!-- 登録フォーム -->
<form method="post" th:action="@{/register}" th:object="${accountRegisterForm}">
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}" />
<!-- ログインID -->
<div class="form-group mt-3">
<label class="form-label" for="loginId">ログインID <span class="badge bg-danger">必須</span></label>
<input type="text" class="form-control" id="loginId" th:field="*{loginId}"
placeholder="8〜18文字で入力" th:errorclass="is-invalid">
<div class="invalid-feedback" th:errors="*{loginId}"></div>
</div>
<!-- パスワード -->
<div class="form-group mt-3">
<label class="form-label" for="password">パスワード <span class="badge bg-danger">必須</span></label>
<input type="password" class="form-control" id="password" th:field="*{password}"
placeholder="8文字以上で入力" th:errorclass="is-invalid">
<div class="invalid-feedback" th:errors="*{password}"></div>
</div>
<!-- パスワード確認 -->
<div class="form-group mt-3">
<label class="form-label" for="passwordConfirm">パスワード(確認) <span class="badge bg-danger">必須</span></label>
<input type="password" class="form-control" id="passwordConfirm" th:field="*{passwordConfirm}"
placeholder="もう一度パスワードを入力" th:errorclass="is-invalid">
<div class="invalid-feedback" th:errors="*{passwordConfirm}"></div>
</div>
<!-- 登録ボタン -->
<div class="text-center mt-4">
<button type="submit" class="btn btn-primary w-100">登録する</button>
</div>
</form>
<!-- ログイン画面へのリンク -->
<div class="text-center mt-3">
<a th:href="@{/toLogin}">既にアカウントをお持ちの方はこちら</a>
</div>
</div>
</body>
</html>
DBにユーザー登録する機能を作成する
こちらも前回の記事で作成したAccountクラスとAccountUserDetailsクラスを流用します。そちらで作成していない方はこちらの記事の「5.1. データ関連のクラス」を参照してください
今回は登録のため追加でアカウント登録フォーム用のDTOとしてAccountRegisterFormと登録完了後のレスポンスDTOとしてAccountRegisterResponseを作成します。
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
import lombok.Data;
/**
* アカウント登録フォーム用DTO
*/
@Data
public class AccountRegisterForm {
// ログインID(8〜18文字)
@NotBlank(message = "ログインIDを入力してください")
@Size(min = 8, max = 18, message = "ログインIDは8文字以上18文字以内で入力してください")
private String loginId;
// パスワード(8文字以上)
@NotBlank(message = "パスワードを入力してください")
@Size(min = 8, max = 100, message = "パスワードは8文字以上100文字以内で入力してください")
private String password;
// パスワード確認用
@NotBlank(message = "確認用パスワードを入力してください")
private String passwordConfirm;
}
import lombok.Builder;
import lombok.Getter;
/**
* アカウント登録完了後のレスポンスDTO
*/
@Getter
@Builder
public class AccountRegisterResponse {
// 登録されたアカウントID
private final Long accountId;
// 登録されたログインID
private final String loginId;
}
Controller
Controllerでは
・登録画面表示
・登録処理
の2つを担当します。
POSTされたフォームはServiceへ渡し、登録完了後はログイン画面へリダイレクトします。
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.validation.BindingResult;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.servlet.mvc.support.RedirectAttributes;
/**
* アカウント登録画面のControllerクラス
*/
@Controller
@RequiredArgsConstructor
@Slf4j
public class AccountRegisterController {
private final AccountRegisterService accountRegisterService;
/**
* アカウント登録画面を表示する
*
* @param model モデル
* @return 登録画面テンプレート
*/
@GetMapping("/register")
public String showRegisterForm(Model model) {
// 空のフォームオブジェクトをモデルにセット
model.addAttribute("accountRegisterForm", new AccountRegisterForm());
return "login/register";
}
/**
* アカウント登録処理を実行する
*
* @param form 登録フォーム
* @param bindingResult バリデーション結果
* @param redirectAttributes リダイレクト属性
* @param model モデル
* @return リダイレクト先またはフォーム画面
*/
@PostMapping("/register")
public String register(
@Valid @ModelAttribute AccountRegisterForm form,
BindingResult bindingResult,
RedirectAttributes redirectAttributes,
Model model) {
// バリデーションエラーがある場合はフォームに戻す
if (bindingResult.hasErrors()) {
return "login/register";
}
try {
accountRegisterService.register(form);
log.info("アカウント登録成功");
redirectAttributes.addFlashAttribute("registerSuccess", "アカウント登録が完了しました。ログインしてください。");
return "redirect:/toLogin";
} catch (IllegalArgumentException e) {
// 例外発生時はエラーメッセージをモデルに詰めて登録画面に戻す
log.warn("アカウント登録失敗");
model.addAttribute("errorMessage", e.getMessage());
return "login/register";
}
}
}
Service
Serviceでは
・パスワード一致確認
・ログインID重複確認
・BCryptによるハッシュ化
・DB保存
という登録処理の本体を実装します。
パスワードはハッシュ化を行ったうえで保存するために、PasswordEncoderを利用してハッシュ化した値のみDBへ保存します。
ログインIDの重複はexistsByLoginIdだけでは不十分であるためデータベース側でもUNIQUE制約が推奨です
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
/**
* アカウント登録のビジネスロジックを担当するServiceクラス
*/
@Service
@RequiredArgsConstructor
@Slf4j
public class AccountRegisterService {
private final AccountRepository accountRepository;
private final PasswordEncoder passwordEncoder;
/**
* 新規アカウントを登録する
*
* @param form アカウント登録フォーム
* @return 登録結果のレスポンスDTO
*/
@Transactional
public AccountRegisterResponse register(AccountRegisterForm form) {
log.info("アカウント登録処理を開始します");
// パスワードと確認用パスワードの一致チェック
if (!form.getPassword().equals(form.getPasswordConfirm())) {
log.warn("パスワード不一致エラー");
throw new IllegalArgumentException("パスワードと確認用パスワードが一致しません");
}
// ログインIDの重複チェック
if (accountRepository.existsByLoginId(form.getLoginId())) {
log.warn("ログインID重複エラー");
throw new IllegalArgumentException("入力内容に誤りがあるか、登録できないログインIDです");
}
// Accountエンティティを生成
Account account = new Account();
account.setLoginId(form.getLoginId());
// パスワードをBCryptでハッシュ化して設定
String encodedPassword = passwordEncoder.encode(form.getPassword());
account.setPassword(encodedPassword);
// DBに保存
Account savedAccount = accountRepository.save(account);
log.info("アカウント登録が完了しました。");
// EntityからレスポンスDTOに変換して返却
return AccountRegisterResponse.builder()
.accountId(savedAccount.getAccountId())
.loginId(savedAccount.getLoginId())
.build();
}
}
Repository
JpaRepositoryを継承することで、
- save()
- existsByLoginId()
などを利用できます。
今回は重複チェックと保存処理で利用します。
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Optional;
public interface AccountRepository extends JpaRepository<Account, Long> {
Optional<Account> findByLoginId(String loginId);
// ログインIDの重複チェック用
boolean existsByLoginId(String loginId);
}
テストを書く
登録機能では
・Controller
・Service
・Repository
の3層をテストします。
ディレクトリ構成
project
├─ src
│ ├─ main
│ └─ test
│ ├─ java
│ │ └─ project
│ │ ├─ entity
│ │ │ └─ AccountTest.java
│ │ ├─ controller
│ │ │ └─ AccountRegisterControllerTest.java
│ │ ├─ service
│ │ │ └─ AccountRegisterServiceTest.java
│ │ └─ repository
│ │ └─ AccountRepositoryTest.java
│ └─ resources
│ └─ application-test.properties
└─ build.gradle
Controller
Controller層のテストでは、登録成功時とログインIDを空で送った場合のケースに加えて、CSRFトークンの検証も実施します。
Controllerのテストでは WebMvcTest を使用し、Spring MVCに関連する層のみをスライスしてテストします。業務ロジックはMockitoBeanでモック化し検証します。
@WebMvcTest(AccountRegisterController.class)
class AccountRegisterControllerTest {
@Autowired
private MockMvc mockMvc;
@MockitoBean
private AccountRegisterService accountRegisterService;
@Test
@DisplayName("登録成功")
@WithMockUser
void testRegister_Success() throws Exception {
// モック設定:登録成功時のレスポンスDTO
AccountRegisterResponse response = AccountRegisterResponse.builder()
.accountId(1L)
.loginId("testuser01")
.build();
when(accountRegisterService.register(any(AccountRegisterForm.class))).thenReturn(response);
// POSTリクエストで登録処理を実行
mockMvc.perform(post("/register")
.with(csrf())
.param("loginId", "testuser01")
.param("password", "password1")
.param("passwordConfirm", "password1"))
.andExpect(status().is3xxRedirection())
.andExpect(redirectedUrl("/toLogin"));
// Serviceのregisterが呼ばれたことを検証
verify(accountRegisterService, times(1)).register(any(AccountRegisterForm.class));
}
@Test
@DisplayName("loginId空でエラー")
@WithMockUser
void testRegister_LoginIdBlank() throws Exception {
// ログインID未入力でPOST
mockMvc.perform(post("/register")
.with(csrf())
.param("loginId", "")
.param("password", "password1")
.param("passwordConfirm", "password1"))
.andExpect(status().isOk())
.andExpect(view().name("login/register"))
.andExpect(model().hasErrors());
// Serviceが呼ばれていないことを検証
verify(accountRegisterService, never()).register(any());
}
}
Service
Service層は認証機能の要であるため、正常系に加えて「パスワード不一致」と「ログインIDの重複」という異常系テストを実装します。
@SpringBootTest
class AccountRegisterServiceTest {
@Autowired
private AccountRegisterService accountRegisterService;
@MockitoBean
private AccountRepository accountRepository;
@MockitoBean
private PasswordEncoder passwordEncoder;
@Test
@DisplayName("正常系 - BCryptエンコードされたパスワードでAccount保存")
void testRegister_Success() {
// テストデータ
AccountRegisterForm form = new AccountRegisterForm();
form.setLoginId("user0001");
form.setPassword("password1");
form.setPasswordConfirm("password1");
// モック設定
when(accountRepository.existsByLoginId("user0001")).thenReturn(false);
when(passwordEncoder.encode("password1")).thenReturn("$2a$10$hashedpassword");
when(accountRepository.save(any(Account.class))).thenAnswer(invocation -> invocation.getArgument(0));
// テスト実行
AccountRegisterResponse result = accountRegisterService.register(form);
// 検証:Repositoryのsaveが呼ばれていること
ArgumentCaptor<Account> captor = ArgumentCaptor.forClass(Account.class);
verify(accountRepository).save(captor.capture());
// 検証:保存されたAccountの内容が正しいこと
Account savedAccount = captor.getValue();
assertThat(savedAccount.getLoginId()).isEqualTo("user0001");
assertThat(savedAccount.getPassword()).isEqualTo("$2a$10$hashedpassword");
// 検証:戻り値のレスポンスDTOが正しいこと
assertThat(result.getLoginId()).isEqualTo("user0001");
}
@Test
@DisplayName("異常系 - パスワードと確認用パスワードが不一致なら例外が発生すること")
void testRegister_PasswordMismatch() {
AccountRegisterForm form = new AccountRegisterForm();
form.setLoginId("user0001");
form.setPassword("password123");
form.setPasswordConfirm("wrongpassword");
assertThatThrownBy(() -> accountRegisterService.register(form))
.isInstanceOf(IllegalArgumentException.class);
verify(accountRepository, never()).save(any());
}
@Test
@DisplayName("異常系 - 既存のログインIDと同じ場合は重複例外が発生すること")
void testRegister_DuplicateId() {
AccountRegisterForm form = new AccountRegisterForm();
form.setLoginId("existuser");
form.setPassword("password123");
form.setPasswordConfirm("password123");
when(accountRepository.existsByLoginId("existuser")).thenReturn(true);
assertThatThrownBy(() -> accountRegisterService.register(form))
.isInstanceOf(IllegalArgumentException.class);
verify(accountRepository, never()).save(any());
}
}
Repository
Repository層のテストでは、実際にテスト用のアカウントをデータベースへ保存し、エンティティの項目が正しく保存されるかを確認していきます。
今回はテスト用DBを作成して検証しています。こちらの設定方法については前回の記事に書いていますのでそちらを参照してください
@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
@ActiveProfiles("test")
@TestPropertySource(properties = "spring.jpa.hibernate.ddl-auto=create-drop")
class AccountRepositoryTest {
@Autowired
private AccountRepository accountRepository;
@Test
@DisplayName("A-R-001:アカウント保存 - save()で保存成功、ID自動採番")
void testSave_Success() {
// テスト用Accountエンティティを生成
Account account = new Account();
account.setLoginId("new00001");
account.setPassword("$2a$10$encoded");
// 保存処理を実行
Account savedAccount = accountRepository.save(account);
// 検証:IDが自動採番されていること
assertThat(savedAccount.getAccountId()).isNotNull();
assertThat(savedAccount.getAccountId()).isGreaterThan(0);
// 検証:ログインIDが正しく保存されていること
assertThat(savedAccount.getLoginId()).isEqualTo("new00001");
// 検証:パスワードが正しく保存されていること
assertThat(savedAccount.getPassword()).isEqualTo("$2a$10$encoded");
}
}
より実践的な実装に向けて
今回は研修で作成したものになっているので基本実装を解説しましたが、実際のプロダクトでは以下のような注意点を考慮する必要があります
- パスワードの要件
- 今回は8文字以上100文字以下にしましたが、実際は長さ・漏えい済みパスワード拒否・レート制限・MFA等も含めて設計することが必要です。
- 連続試行回数
- 短時間の登録連続試行・総当たりを防ぐためのレートリミット(回数制限)やアカウントロック機能が求められます
まとめ
今回は、Spring Securityを利用した「新規アカウント登録機能」を実装しました。
実装した内容は以下の通りです。
- フォームバリデーション: Thymeleaf と Bean Validation を組み合わせ、文字数制限や必須入力チェックを画面・サーバー側双方で実施
- 業務バリデーション: Service層で「パスワードの一致チェック」「ログインIDの重複チェック」を行い、不正なデータの保存をブロック
-
パスワードのハッシュ化: 平文パスワードをそのままDBに保存せず、
PasswordEncoder(BCrypt) でハッシュ化
前回の「ログイン機能」と今回の「新規登録機能」により、Webアプリケーションの認証基礎が揃いました。
まだ研修用でセキュアな認証基盤ではないですが、これらの知識を拡張してよりセキュアな認証ができるような仕組みを引き続き学んでいきたいと思います