はじめに
新卒1年目のエンジニアhirotoです。
今回は研修の中で作成したWebアプリケーションでログイン機能を追加したのでその実装について紹介します
この記事では、研修で作成した学習用アプリを題材に、Spring SecurityでDB認証を組み込む基本の流れを整理します。
なお、ここで紹介する実装は学習目的の最小構成です。本番運用では、認可設定、監査ログ、ログイン試行回数制限、多要素認証、機密情報の外部管理など、追加のセキュリティ対策が必要です。
技術スタック
- 言語:Java(JDK 21)
- ビルドツール:Gradle 9.6.1
- フレームワーク:Spring Boot 4.1.0
- テンプレートエンジン:Thymeleaf
- DB:Postgres 15.18
Spring Securityとは
Spring Securityは、Springベースのアプリケーションに認証・認可などのセキュリティ機能を追加するためのフレームワークです。フォームログインやHTTP Basic認証、OAuth 2.0、OpenID Connectなどの認証方式に対応しているほか、CSRF対策やセッション管理など、Webアプリケーションに必要なセキュリティ機能を提供します。参考記事[1]
実装
ディレクトリ構成
今回のログイン機能で使用するファイルの構成は、以下のとおりです。
Spring Securityの設定、画面表示、データベースからのアカウント取得など、役割ごとにクラスを分けて実装します。
それぞれの主な役割は次のとおりです。
-
SecurityConfig:ログインが必要なURLやログイン処理の設定を行う -
LoginController:ログイン画面を表示する -
LoginService:ログインIDからアカウント情報を取得する -
AccountRepository:データベースのアカウント情報を検索する -
Account:アカウントテーブルのデータ構造を表す -
AccountUserDetails:取得したアカウント情報をSpring Securityで扱える形式に変換する
project
├─ src
│ └─ main
│ ├─ java
│ │ └─ package
│ │ ├─ config
│ │ │ └─ SecurityConfig.java //パスに対する認可設定
│ │ ├─ controller
│ │ │ └─ LoginController.java // ログインの窓口
│ │ ├─ service
│ │ │ └─ LoginService.java //ログインのメイン処理
│ │ ├─ repository
│ │ │ └─ AccountRepository.java //ログインで使うアカウントのCRUD操作を行う
│ │ └─ entity
│ │ └─ Account.java //アカウントのデータモデル
│ └─ resources
│ ├─ templates
│ │ └─ login
│ │ └─ login.html //ログイン画面
│ └─ application.properties
└─ build.gradle
1. Gradleに依存関係を設定する
はじめに、Spring Securityを使用するために必要な依存関係をbuild.gradleへ追加します。
今回追加する依存関係の役割は以下のとおりです。
spring-boot-starter-security:Spring Securityによる認証・認可機能を使用する
thymeleaf-extras-springsecurity6:ThymeleafとSpring Securityを連携する
spring-security-test:Spring Securityの認証処理をテストする
依存関係を追加した後は、Gradleプロジェクトを再読み込みしてください。
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-security'
implementation 'org.thymeleaf.extras:thymeleaf-extras-springsecurity6'
testImplementation 'org.springframework.security:spring-security-test'
}
2. DB接続設定を行う
今回のログイン処理では、PostgreSQLに保存されたアカウント情報を使用して認証を行います。
そのため、application.propertiesに接続先のデータベース名、ユーザー名、パスワードなどを設定します。
掲載している値はサンプルのため、自分の環境に合わせて変更してください。
また、実際の開発では、パスワードなどの機密情報をソースコード管理へ含めないよう注意してください。
spring.datasource.url=jdbc:postgresql://localhost:5432/your_database
spring.datasource.username=your_username
spring.datasource.password=your_password
spring.datasource.driver-class-name=org.postgresql.Driver
# 以下ローカル用設定のため本番環境は未推奨
# spring.jpa.hibernate.ddl-auto=update
# spring.jpa.show-sql=true
アカウント情報はaccountというテーブルを使用するため、以下のSQLを上記で設定したdatabaseで実行してテーブルを作成してください
CREATE TABLE IF NOT EXISTS account (
account_id BIGSERIAL NOT NULL,
login_id VARCHAR(18) NOT NULL UNIQUE,
password VARCHAR(100) NOT NULL,
PRIMARY KEY (account_id)
);
3. ログイン画面を作る
ログインするためのユーザーは新規登録画面で作成するもしくはDBに直接INSERTする必要があります。新規登録画面については別途記事を作成していますのでそちらを参照してください
※DBに直接INSERTは本番環境などでは避けてください。ローカル環境などで試す場合のみ使える手法です
次に、ログインIDとパスワードを入力するログイン画面を作成します。
フォームから送信されたログイン情報は、ControllerではなくSpring Securityが受け取ります。
今回の設定では、フォームからPOST /loginへ次の値を送信します。
username:ログインID
password:パスワード
Spring SecurityではCSRF対策が標準で有効になっているため、フォームにはCSRFトークンも含めます。
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head th:replace="~{common/head :: head_fragment(scripts = ~{::script}, links = ~{::link})}">
</head>
<body>
<div class="login-container">
<h2>ログイン</h2>
<!-- アカウント登録成功メッセージ -->
<div th:if="${registerSuccess}" class="alert alert-success" role="alert">
<span th:text="${registerSuccess}"></span>
</div>
<form method="post" th:action="@{/login}">
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}" />
<div class="form-group">
<!-- SecurityConfigのusernameパラメータを利用するためusernameとなっているが入力はlogin_id -->
<label for="username">ログインID</label>
<input type="text" id="username" name="username" required>
</div>
<div class="form-group">
<label for="password">パスワード</label>
<input type="password" id="password" name="password" required>
</div>
<button type="submit" class="login-button">ログイン</button>
</form>
<div th:if="${param.error}">
<div class="alert alert-danger">
<span th:text="${'ログインIDまたはパスワードが正しくありません'}"></span>
</div>
</div>
<!-- 新規登録リンク -->
<div class="text-center mt-3">
<a th:href="@{/register}">新規アカウント登録はこちら</a>
</div>
</div>
</body>
</html>
4. 認証設定を行う
SecurityConfig.javaというファイルを作成し、どのような認証権限を与えるかを設定します。SecurityConfigでは、ログインしていないユーザーがアクセスできるURLや、ログイン成功後・失敗後の遷移先を設定します。
今回の設定では、ログイン画面、新規登録画面、CSSファイルには認証なしでアクセスできるようにし、それ以外の画面はログイン必須とします。
また、フォームから送信されたPOST /loginは、Spring Securityが認証処理として受け取ります。そのため、ログイン処理用のControllerを作成する必要はありません。
今回ログアウトに関する設定も行っていますがこちらの記事では紹介しません
主な設定
-
requestMatchers(...).permitAll():認証なしでアクセスできるURLを指定する -
anyRequest().authenticated():それ以外のURLをログイン必須にする -
loginPage():ログイン画面のURLを指定する -
loginProcessingUrl():ログインフォームの送信先を指定する -
defaultSuccessUrl():ログイン成功後の遷移先を指定する -
failureUrl():ログイン失敗後の遷移先を指定する
今回は研修での簡易的な認証基盤の構築のため認可について扱っていません。実稼働システムではhasRole()などで認可権限も行う設定をする必要があります
SecurityConfigの実装
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
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http.authorizeHttpRequests(auth -> auth
.requestMatchers("/toLogin", "/register", "/css/**").permitAll()
.anyRequest().authenticated()
)
.formLogin(login -> login
.loginPage("/toLogin")
.loginProcessingUrl("/login")
.usernameParameter("username")
.passwordParameter("password")
.defaultSuccessUrl("/user/list", true) //ユーザーリストという一覧画面が別途ありそこを初期表示に設定しています
.failureUrl("/toLogin?error")
.permitAll()
)
.logout(logout -> logout
.logoutUrl("/logout")
.logoutSuccessUrl("/toLogin?logout")
.invalidateHttpSession(true)
.deleteCookies("JSESSIONID")
.permitAll()
);
return http.build();
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
}
5. ログインに関する処理を追加する
ここからは、データベースに保存されているアカウントを取得し、Spring Securityへ渡す処理を実装します。
ログイン時の大まかな処理の流れは、次のとおりです。
- ログインフォームからログインIDとパスワードを送信する
- Spring SecurityがログインIDをLoginServiceへ渡す
- LoginServiceがAccountRepositoryを使用してアカウントを検索する
- 取得したアカウント情報をAccountUserDetailsへ変換する
- Spring Securityが入力されたパスワードとDBのパスワードを比較する
- 認証結果に応じて、成功画面またはログイン画面へ遷移する
5.1. データ関連のクラス
まず、データベースのアカウント情報を扱うAccountクラスと、Spring Securityで認証情報を扱うAccountUserDetailsクラスを作成します。
Accountは、PostgreSQLのaccountテーブルに対応するEntityです。
一方、AccountUserDetailsはSpring Security標準のUserクラスを継承し、ログインID、パスワード、権限情報を保持します。
今回は、ログイン後の処理でアカウントIDを利用できるように、独自のaccountIdも追加しています。
import jakarta.persistence.*;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
import lombok.Data;
@Entity
@Data
@Table(name = "account")
public class Account {
@Id
@Column(name = "account_id")
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long accountId;
@Column(name = "login_id", unique = true)
@NotBlank(message = "ログインIDを入力してください")
@Size(min = 8, max = 18, message = "IDは8文字以上18文字以内で入力してください")
private String loginId;
@Column(name = "password")
@Size(max = 100, message = "パスワードは100文字以内で入力してください")
private String password;
}
import java.util.Collection;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.userdetails.User;
public class AccountUserDetails extends User {
private final Long accountId;
public AccountUserDetails(String loginId, String password, Long accountId,
Collection<? extends GrantedAuthority> authorities) {
super(loginId, password, authorities);
this.accountId = accountId;
}
public Long getAccountId() {
return accountId;
}
}
5.2. Controller層
Controller層では、ログイン画面を表示する処理を実装します。
GET /toLoginへアクセスすると、templates/login/login.htmlが表示されます。
なお、フォーム送信後の認証処理はSpring Securityが担当するため、このControllerにはPOST /loginの処理は作成しません。
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@Controller
public class LoginController {
// ログ出力用のLoggerインスタンス
private final Logger logger = LoggerFactory.getLogger(this.getClass());
// ログイン画面表示処理
@GetMapping("/toLogin")
public String toLogin(){
logger.info("ログイン画面表示処理を開始します。");
return "login/login";
}
}
5.3. Service層
Service層では、ログインフォームから受け取ったログインIDを使って、データベースからアカウント情報を取得します。
LoginServiceはSpring SecurityのUserDetailsServiceを実装します。
認証時には、Spring SecurityからloadUserByUsernameメソッドが呼び出されます。
アカウントが見つかった場合はAccountUserDetailsへ変換して返し、見つからなかった場合はUsernameNotFoundExceptionを送出します。
パスワードの照合自体は、このメソッド内では行いません。返却したパスワードとログインフォームから入力されたパスワードを、Spring SecurityがPasswordEncoderを使用して照合します。
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.jspecify.annotations.NonNull;
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 java.util.Collections;
/**
* ログイン認証を担当するServiceクラス
* Spring SecurityのUserDetailsServiceを実装し、アカウント情報をロードする
*/
@Service
@RequiredArgsConstructor
@Slf4j
public class LoginService implements UserDetailsService {
private final AccountRepository accountRepository;
/**
* ログインIDからユーザー情報をロードする
*
* @param username ログインID
* @return UserDetails認証情報(アカウントIDを含む)
* @throws UsernameNotFoundException ユーザーが見つからない場合
*/
@Override
public UserDetails loadUserByUsername(@NonNull String username) throws UsernameNotFoundException {
log.debug("ユーザー認証処理を開始します。");
// ログインIDでアカウントを検索
Account account = accountRepository.findByLoginId(username)
.orElseThrow(() -> {
log.warn("該当するユーザーが存在しないため、認証に失敗しました。");
return new UsernameNotFoundException("ユーザーが見つかりません");
});
log.info("ユーザー情報の取得に成功しました");
// AccountUserDetailsに変換して返却
return new AccountUserDetails(
account.getLoginId(),
account.getPassword(),
account.getAccountId(),
Collections.emptyList()
);
}
}
5.4. Repository層
Repository層では、データベースからログインIDに一致するアカウントを検索します。
JpaRepositoryを継承することで、SQLを直接記述しなくても、メソッド名から検索処理を生成できます。
findByLoginIdは、該当するアカウントが存在する場合はAccountを返し、存在しない場合はOptional.empty()を返します。
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Optional;
public interface AccountRepository extends JpaRepository<Account, Long> {
Optional<Account> findByLoginId(String loginId);
}
6. テストを書く
最後に、実装したログイン処理が想定どおりに動作することをテストします。
今回は、次の3つの層をそれぞれテストします。
-
Controller層:ログイン成功時・失敗時の画面遷移
-
Service層:ログインIDからアカウント情報を取得できるか
-
Repository層:データベースからアカウントを検索できるか
今回のパスワードは新規登録の方でBCryptでハッシュ化して保存しています。ハッシュ化などの方法については新規登録の方の記事を参照してください
6.1. ディレクトリ構成
テストコードはsrc/test/java以下に配置します。
本番コードと同じように、Controller、Service、Repositoryごとにパッケージを分けます。
テスト用のDBを事前に作って検証しています。同じテストを行う場合はテスト用のDBを作成し、application-test.propertiesに設定してください
project
├─ src
│ ├─ main
│ └─ test
│ ├─ java
│ │ └─ project
│ │ ├─ controller
│ │ │ └─ LoginControllerTest.java
│ │ ├─ service
│ │ │ └─ LoginServiceTest.java
│ │ └─ repository
│ │ └─ AccountRepositoryTest.java
│ └─ resources
│ └─ application-test.properties
└─ build.gradle
6.2. Controller層
Controller層のテストでは、Spring Securityのログイン処理へリクエストを送信し、ログイン成功時と失敗時の遷移先を確認します。
Spring SecurityではCSRF対策が有効になっているため、テストリクエストにもcsrfトークンを付与します。
ログイン成功時は/user/listへ、失敗時は/toLogin?errorへリダイレクトされることを検証します。
@SpringBootTest
@AutoConfigureMockMvc
class LoginControllerTest {
@Autowired
private MockMvc mockMvc;
@MockitoBean
private LoginService loginService;
@Test
@DisplayName("ログイン成功 - /user/listへリダイレクト")
void testLogin_Success() throws Exception {
// テスト用パスワードをBCryptでエンコード
String rawPassword = "password1";
String encodedPassword = new BCryptPasswordEncoder().encode(rawPassword);
// モック設定:LoginServiceがBCryptエンコード済みパスワードを持つUserDetailsを返す
AccountUserDetails userDetails = new AccountUserDetails(
"user0001",
encodedPassword,
1L,
java.util.Collections.emptyList()
);
when(loginService.loadUserByUsername("user0001")).thenReturn(userDetails);
// テスト実行:Spring Securityのログイン処理にPOST
mockMvc.perform(post("/login")
.with(csrf())
.param("username", "user0001")
.param("password", rawPassword))
.andExpect(status().is3xxRedirection())
.andExpect(redirectedUrl("/user/list"));
}
@Test
@DisplayName("ログイン失敗(loginID不正) - /toLogin?errorへリダイレクト")
void testLogin_Failure_WrongLoginId() throws Exception {
// モック設定:LoginServiceがUsernameNotFoundExceptionをスロー
when(loginService.loadUserByUsername("wronguser"))
.thenThrow(new org.springframework.security.core.userdetails.UsernameNotFoundException("ユーザーが見つかりません"));
// テスト実行
mockMvc.perform(post("/login")
.with(csrf())
.param("username", "wronguser")
.param("password", "password1"))
.andExpect(status().is3xxRedirection())
.andExpect(redirectedUrl("/toLogin?error"));
}
}
6.3. Service層
Service層のテストでは、AccountRepositoryをモック化し、ログインIDに対応するアカウントを取得できることを確認します。
正常系では、取得したアカウント情報がAccountUserDetailsへ正しく変換されることを検証します。
異常系では、アカウントが存在しない場合にUsernameNotFoundExceptionが発生することを検証します。
@SpringBootTest
class LoginServiceTest {
@Autowired
private LoginService loginService;
@MockitoBean
private AccountRepository accountRepository;
@Test
@DisplayName("L-S-001:loadUserByUsername正常系 - 登録済みIDでUserDetails返却")
void testLoadUserByUsername_Success() {
// テスト用パスワードをBCryptでエンコード
String rawPassword = "password1";
String encodedPassword = new BCryptPasswordEncoder().encode(rawPassword);
// テスト用Accountエンティティを生成
Account account = new Account();
account.setAccountId(1L);
account.setLoginId("user0001");
account.setPassword(encodedPassword);
// モック設定:ログインIDでアカウントが見つかる
when(accountRepository.findByLoginId("user0001")).thenReturn(Optional.of(account));
// テスト実行
UserDetails result = loginService.loadUserByUsername("user0001");
// 検証:返却されたUserDetailsの内容が正しいこと
assertThat(result).isInstanceOf(AccountUserDetails.class);
assertThat(result.getUsername()).isEqualTo("user0001");
assertThat(result.getPassword()).isEqualTo(encodedPassword);
// 検証:AccountUserDetailsにaccountIdがセットされていること
AccountUserDetails details = (AccountUserDetails) result;
assertThat(details.getAccountId()).isEqualTo(1L);
// 検証:Repositoryが呼ばれたこと
verify(accountRepository, times(1)).findByLoginId("user0001");
}
@Test
@DisplayName("L-S-002:loadUserByUsername異常系 - 未登録IDで例外スロー")
void testLoadUserByUsername_NotFound() {
// モック設定:ログインIDでアカウントが見つからない
when(accountRepository.findByLoginId("noexist")).thenReturn(Optional.empty());
// テスト実行 & 検証:UsernameNotFoundExceptionがスローされること
assertThatThrownBy(() -> loginService.loadUserByUsername("noexist"))
.isInstanceOf(UsernameNotFoundException.class)
.hasMessageContaining("ユーザーが見つかりません");
// 検証:Repositoryが呼ばれたこと
verify(accountRepository, times(1)).findByLoginId("noexist");
}
}
6.4. Repository層
Repository層のテストでは、実際にテスト用のアカウントをデータベースへ登録し、findByLoginIdで取得できることを確認します。
また、存在しないログインIDを指定した場合に、空のOptionalが返されることも確認します。
@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) //テスト用DB使用
@ActiveProfiles("test")
@TestPropertySource(properties = "spring.jpa.hibernate.ddl-auto=create-drop")
class AccountRepositoryTest {
@Autowired
private AccountRepository accountRepository;
@Autowired
private TestEntityManager entityManager;
@Test
@DisplayName("findByLoginId - 存在するアカウントが取得できる")
void testFindByLoginId_Found() {
// テスト用パスワードをBCryptでエンコード
String encodedPassword = new BCryptPasswordEncoder().encode("password1");
// テストデータをDBに挿入
Account account = new Account();
account.setLoginId("user0001");
account.setPassword(encodedPassword);
entityManager.persistAndFlush(account);
// ログインIDで検索
Optional<Account> result = accountRepository.findByLoginId("user0001");
// 検証:アカウントが見つかること
assertThat(result).isPresent();
assertThat(result.get().getLoginId()).isEqualTo("user0001");
assertThat(result.get().getPassword()).isEqualTo(encodedPassword);
}
@Test
@DisplayName("findByLoginId - 存在しないアカウントはOptional.empty返却")
void testFindByLoginId_NotFound() {
// 存在しないログインIDで検索
Optional<Account> result = accountRepository.findByLoginId("noexist0");
// 検証:空のOptionalが返ること
assertThat(result).isEmpty();
}
}
実運用で扱うためには
今回の実装は、Spring Securityを用いた基礎的なDB認証の仕組みを理解するためのものです。
実際の環境で運用する場合は、セキュリティや運用性を高めるために以下のような対策や機能拡張を検討する必要があります。
- ブルートフォース攻撃対策: ログイン試行回数の制限やアカウントロック機能の実装
- 監査ログの取得: 誰が・いつ・ログインに成功/失敗したかの厳密な記録
- より高度な認証基盤: 多要素認証(MFA)の導入や、OAuth 2.0 / OIDC(SSO)連携の検討
- パスワードポリシーの強化: 強度チェックや定期変更を促す仕組み
セキュリティの基礎を理解した上で、システムの要件に合わせてこれらの機能を段階的に拡張していくことが大切です。
まとめ
今回は研修で実装したログイン機能について紹介しました。Spring SecurityとPostgreSQLを使用し、データベースに登録されたアカウント情報でログインする機能を実装しました。
Spring Securityを使用することで、ログインリクエストの受け取りやパスワード照合、セッション管理などを自分で一から実装せずに、認証機能を構築できるのはとても便利だと感じました。
Spring Securityを活用してOpenID認証も実装できるようなのでチャレンジしてみたいと思います