背景
現在関わっている案件でSpringBoot(Java)を使ったWebアプリの開発をしている。
認証・認可周りの実装ではSpringSecurityを使用した。
要件の中で「会社ID」「ユーザーID」「パスワード」の3つの項目でログイン認証するという要件があったのだが、なかなかうまくいかずに結構ハマってしまった。
最終的にはうまくいったが、その過程で色々と得られた部分もあったので、知識の整理と備忘録用の記事としてまとめる。
前提知識のおさらい
関連する前提知識をざっとおさらい。
- SpringFramework
- Javaの汎用フレームワーク
- SpringBoot
- Webアプリ開発に特化したJavaのフレームワーク
- Spring FrameworkをWebアプリに特化させてより簡単な設定で動くようにしたもの
- SpringSecurity
- SpringFramework、及びSpringBootで利用可能な認証・認可周りのフレームワーク
- 設定ファイルで権限によるアクセス制御などが簡単に実現できる
SpringSecurityの概要
SpringFramework, SpringBootで利用可能な、認証・認可周りをいい感じにやってくれるフレームワーク。
ユーザーが持つ権限によるアクセス制御が設定ファイルで簡単に実現できる。
SpringSecurityは導入した段階でデフォルトの機能が多く備わっていて、少ない設定でも権限周りの制御が色々できる。
もちろん要件に合わせて独自にカスタマイズすることも可能。
SpringSecurityのライブラリに様々なインターフェースが用意されているので、カスタマイズする場合は必要なインターフェースを実装したクラスを作成し、設定ファイルで実装クラスが動作するように定義する。
ログイン成功時、ログイン失敗時、ログアウト時など、様々なイベントに合わせたイベントハンドラをインターフェースとして提供しているため、クラスを疎結合にしたまま様々なカスタマイズができる。
環境
今回のサンプルコードは以下のバージョンを前提とする。
- Java 21
- SpringBoot 3.3.2
- SpringSecurity 6.3
ここではフロントエンドを分けずに、全てSpringBootで実装するものとする。
フロントとバックエンドを切り離して実装する場合は今回の方法は使用できません。
また、サンプルコード用に実際に動くこコードから手を加えているので、そのままコピーしても正常に動作しない可能性もありますので、ご了承ください。
要件
認証に関する要件は、ログイン時に「会社ID」「ユーザーID」「パスワード」の3つの項目で認証するというもの。
一般的にWebアプリケーションでは「ユーザーID」と「パスワード」の2つの項目で認証する場合が多いが、今回の要件はその2つに会社IDを追加して3つの項目で認証を行う。
データ構造としては、ユーザー情報を格納するテーブルに会社IDを保持する。
認証処理の流れとしては、会社IDとユーザーIDを検索条件にユーザー情報を取得する。
データが取得できなければ認証失敗。
データが取得できた場合は、パスワードの入力値をハッシュ化した値と、DBに格納されているパスワードの値を比較して認証を行う。
結論
先に今回のカスタマイズで実装、及び修正が必要になったファイルの一覧を上げます。
- pom.xml
- Mavenの設定ファイル。SpringSecurityの依存関係を追加
- login.html
- ログイン画面
- LoginController.java
- ログイン画面へのルーティングを定義するコントローラ
- CompanyIdUsernamePasswordAuthenticationFilter.java
- 認証時のフィルター
- ここで会社IDをリクエストパラメータから取得する
- CompanyIdUsernamePasswordAuthenticationToken.java
- 認証済みのトークンを表すクラス
- 会社ID、ユーザーID、パスワードの情報を保持するためのクラス
- CompanyIdUsernamePasswordAuthenticationProvider.java
- 実際の認証処理を行うプロバイダー
- LoginService.java
- 会社ID、ユーザーIDをもとにユーザー格納テーブルからユーザー情報を格納するクラス
- 一般的なサービスクラスと同様の役割
- CustomUserDetails.java
- UserDetailsインターフェースの実装クラス
- セッションに保持されるユーザー情報オブジェクトとなるクラス
- SecurityConfig.java
- SpringSecurity用の設定ファイル
詳細
以下、具体的なコードとそのざっくり解説。
ここではSpringBoot自体の環境構築の手順は省略します。
pom.xml
まずはpom.xmlにSpringSecurity用の依存関係を追加する。
今回はMavenプロジェクトを前提とする。
Gradleの場合はgradle.build.ktsなどに対象の依存関係を追加する。
フロントをThymeleafで実装し、権限によってフロントの制御を行う場合はThymeleaf用のSpringSecurity拡張機能も追加しておく。
その他の依存関係はここでは省略。
DBにアクセスするためのjdbc、jpaなどは環境に合わせて適宜追加しておく。
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>org.thymeleaf.extras</groupId>
<artifactId>thymeleaf-extras-springsecurity6</artifactId>
</dependency>
<!-- 以下略 -->
login.html
SpringSecurityではデフォルトのログイン画面が用意されている。
デフォルトのログイン画面は非常にシンプルで、入力項目としてユーザー名とパスワードが容易されており、それぞれ「username」と「password」というパラメータで送信される。
画面をカスタマイズしたい場合はHTMLで画面を実装し、コントローラでルーティングを定義する。
今回は要件に合わせて会社IDを含めた3つの入力項目を持つログイン画面(htmlファイル)を用意する。
ユーザー名のパラメータはSpringSecurityのデフォルトが「username」なので、そのまま踏襲する。
form要素のactionをth:action="@{/login}"
と指定しないとSpringSecurityの機能に正常にリクエストが送られないので注意。
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>ログイン</title>
<link href="/css/login.css" rel="stylesheet">
</head>
<body>
<form th:action="@{/login}" method="post">
<div th:if="${param.error}">ログインできません</div>
<label>
会社ID
<input name="companyId" type="text">
</label>
<label>
ユーザー名
<input name="username" type="text">
</label>
<label>
パスワード
<input name="password" type="password">
</label>
<button type="submit">ログイン</button>
</form>
</body>
</html>
※CSSを読み込んでいるが本題と関係ないのでここでは省略
LoginController
カスタマイズしたhtmlに遷移するようにコントローラを定義。
SecurityConfig(後で掲載)で、ログインフォームのパスとして/login
を指定する。
package com.example.demo.login;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
@Controller
public class LoginController {
@GetMapping("/login")
public String login() {
return "login";
}
}
CompanyIdUsernamePasswordAuthenticationFilter
SpringSecurityではログイン認証時にいくつかのFilterの処理が実行される。
各Filterの中で1つも認証の処理が成功すれば、全体を通して認証成功とみなされる仕組みらしい。
デフォルトのFilterの1つにUsernamePasswordAuthenticationFilter
があり、このFilterでユーザー名とパスワードによる認証が行われる。
ただし、このFilterではusername
とpassword
の2つのパラメータしか受け取っていないため、3つの項目で認証を行うには、カスタマイズしたFilterを追加する必要がある。
ここでは、既存のUsernamePasswordAuthenticationFilter
を継承し、新しく会社IDもパラメータとして受け取るFilterを用意する。
また、Filterでは認証成功時の処理や認証失敗時の処理もカスタマイズすることができる。
ここで認証成功時の処理もカスタマイズしないと正常に動作しなかったので、処理を実装している。
認証成功時、セッションにユーザー情報が格納されるように処理を実装。
package com.example.demo.login;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.AuthenticationServiceException;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.core.context.SecurityContext;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.context.SecurityContextHolderStrategy;
import org.springframework.security.web.authentication.AuthenticationFailureHandler;
import org.springframework.security.web.authentication.AuthenticationSuccessHandler;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import org.springframework.security.web.context.HttpSessionSecurityContextRepository;
import org.springframework.security.web.context.SecurityContextRepository;
import java.io.IOException;
public class CompanyIdUsernamePasswordAuthenticationFilter extends UsernamePasswordAuthenticationFilter {
private final AuthenticationSuccessHandler successHandler;
private final AuthenticationFailureHandler failureHandler;
private final SecurityContextHolderStrategy securityContextHolderStrategy = SecurityContextHolder.getContextHolderStrategy();
private final SecurityContextRepository securityContextRepository = new HttpSessionSecurityContextRepository();
public CompanyIdUsernamePasswordAuthenticationFilter(AuthenticationManager authenticationManager,
AuthenticationSuccessHandler successHandler,
AuthenticationFailureHandler failureHandler) {
super(authenticationManager);
this.successHandler = successHandler;
this.failureHandler = failureHandler;
}
@Override
public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException {
if (!request.getMethod().equals("POST")) {
throw new AuthenticationServiceException("Authentication method not supported: " + request.getMethod());
} else {
String username = this.obtainUsername(request);
username = username != null ? username.trim() : "";
String password = this.obtainPassword(request);
password = password != null ? password : "";
// ここで会社IDを受け取る
String companyId = request.getParameter("companyId");
companyId = companyId != null ? companyId : "";
// カスタマイズしたトークンに渡す
var authRequest = new CompanyIdUsernamePasswordAuthenticationToken(username, password, companyId);
this.setDetails(request, authRequest);
return this.getAuthenticationManager().authenticate(authRequest);
}
}
// これを実装しないと、認証が通っても後のフィルターで未認証判定になってしまうので注意
@Override
protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, Authentication authResult) throws IOException, ServletException {
SecurityContext context = this.securityContextHolderStrategy.getContext();
context.setAuthentication(authResult);
this.securityContextHolderStrategy.setContext(context);
this.securityContextRepository.saveContext(context, request, response);
successHandler.onAuthenticationSuccess(request, response, authResult);
}
@Override
protected void unsuccessfulAuthentication(HttpServletRequest request, HttpServletResponse response, AuthenticationException failed) throws IOException, ServletException {
this.securityContextHolderStrategy.clearContext();
failureHandler.onAuthenticationFailure(request, response, failed);
}
}
CompanyIdUsernamePasswordAuthenticationToken
認証用のトークンを表すクラス。
既存でUsernamePasswordAuthenticationToken
クラスがあるので、継承して会社IDも保持するように実装する。
先のFilterでインスタンスを生成する。
Providerで認証済みの状態にするためにこのTokenを使用する。
package com.example.demo.login;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.SpringSecurityCoreVersion;
import java.util.Collection;
public class CompanyIdUsernamePasswordAuthenticationToken extends UsernamePasswordAuthenticationToken {
private static final long serialVersionUID = SpringSecurityCoreVersion.SERIAL_VERSION_UID;
private final String companyId;
public CompanyIdUsernamePasswordAuthenticationToken(
Object principal, Object credentials, String companyId) {
super(principal, credentials);
this.companyId = companyId;
}
public CompanyIdUsernamePasswordAuthenticationToken(
Object principal, Object credentials, String companyId,
Collection<? extends GrantedAuthority> authorities) {
super(principal, credentials, authorities);
this.companyId = companyId;
}
public String getCompanyId() {
return companyId;
}
}
CompanyIdUsernamePasswordAuthenticationProvider
実際の認証処理を行うプロバイダー。
authenticate
メソッドにて認証を行う。
LoginServiceからユーザー情報を取得するし、パスワードが正しいかを検証する。
package com.example.demo.login;
import org.springframework.security.authentication.AuthenticationProvider;
import org.springframework.security.authentication.BadCredentialsException;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.crypto.password.PasswordEncoder;
public class CompanyIdUsernamePasswordAuthenticationProvider implements AuthenticationProvider {
private LoginService loginService;
private PasswordEncoder passwordEncoder;
public void setPasswordEncoder(PasswordEncoder passwordEncoder) {
this.passwordEncoder = passwordEncoder;
}
public void setLoginService(LoginService loginService) {
this.loginService = loginService;
}
@Override
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
var companyIdUsernamePasswordAuthentication =
(CompanyIdUsernamePasswordAuthenticationToken) authentication;
var companyId = companyIdUsernamePasswordAuthentication.getCompanyId();
var accountId = (String)companyIdUsernamePasswordAuthentication.getPrincipal();
var userDetails = loginService.loadUserByCompanyIdAndAccountId(companyId, accountId);
String presentedPassword = authentication.getCredentials().toString();
if (!this.passwordEncoder.matches(presentedPassword, userDetails.getPassword())) {
throw new BadCredentialsException("AbstractUserDetailsAuthenticationProvider.badCredentials");
}
var result = new CompanyIdUsernamePasswordAuthenticationToken(
// authentication.getPrincipal(),
userDetails,
authentication.getCredentials(),
companyIdUsernamePasswordAuthentication.getCompanyId(),
userDetails.getAuthorities()
);
result.setDetails(authentication.getDetails());
return result;
}
@Override
public boolean supports(Class<?> authentication) {
return CompanyIdUsernamePasswordAuthenticationToken.class
.isAssignableFrom(authentication);
}
}
LoginService.java
会社IDとユーザーIDからユーザー情報を取得し、権限を設定してUserDetailsのインスタンスを返却する。
UserRecordとUserRepositoryの実装はここでは省略。
UserRecordはユーザー情報を格納するテーブルに対応するEntityとする。
UserRepositoryはユーザー情報を格納するテーブルからデータを取得するDAOクラスとして実装とする。
package com.example.demo.login;
import com.example.demo.login.*;
import lombok.AllArgsConstructor;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;
import java.util.Collection;
@Service
@AllArgsConstructor
public class LoginService {
private final UserRepository userRepository;
public UserDetails loadUserByCompanyIdAndAccountId(String companyId, String accountId) throws UsernameNotFoundException {
var userRecord = userRepository.findByAccountIdAndCompanyId(accountId, companyId);
if(userRecord == null) {
throw new UsernameNotFoundException("User nor found with login id: " + accountId);
}
return new CustomUserDetails(userRecord.userId(),
userRecord.accountId(),
userRecord.password(),
userRecord.companyId(),
getAuthorities(userRecord));
}
private Collection<? extends GrantedAuthority> getAuthorities(UserRecord userRecord) {
if(userRecord.role.equals("USER")) {
return List.of(new SimpleGrantedAuthority("ROLE_USER"))
} else {
return List.of(new SimpleGrantedAuthority("ROLE_USER"), new SimpleGrantedAuthority("ROLE_ADMIN"))
}
}
}
CustomUserDetails.java
セッションに保持されるユーザーオブジェクト用のクラス。
UserDetails
インターフェースを実装したクラスとして作成する。
package com.example.demo.login;
import com.example.demo.login.*;
import lombok.Getter;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
import java.io.Serial;
import java.io.Serializable;
import java.util.Collection;
public class CustomUserDetails implements UserDetails {
private final String username;
private final String password;
@Getter
private final String companyId;
private final Collection<? extends GrantedAuthority> authorities;
public CustomUserDetails(String username
, String password
, String companyId
, Collection<? extends GrantedAuthority> authorities) {
this.username = username;
this.password = password;
this.companyId = companyId;
this.authorities = authorities;
}
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return authorities;
}
@Override
public String getPassword() {
return password;
}
@Override
public String getUsername() {
return username;
}
}
SecurityConfig
SpringSecurity用の設定ファイル。
SpringFrameworkだとxmlなどで設定を行う必要があるが、SpringBootではクラスで設定ファイルを定義可能。
ここまで作成したFilter, Providerなどが正しく呼ばれるように設定を行う。
ポイントだったのは、AuthenticationManagerにProviderを設定してあげないと、Providerの処理が実行されなかったこと。
package com.example.demo;
import com.example.demo.login.*;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.AuthenticationProvider;
import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration;
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;
import org.springframework.security.web.authentication.AuthenticationFailureHandler;
import org.springframework.security.web.authentication.AuthenticationSuccessHandler;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import org.springframework.security.web.authentication.logout.LogoutHandler;
import org.springframework.security.web.authentication.logout.LogoutSuccessHandler;
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http, CompanyIdUsernamePasswordAuthenticationFilter companyIdUsernamePasswordAuthenticationFilter) throws Exception {
http
.addFilterBefore(companyIdUsernamePasswordAuthenticationFilter, UsernamePasswordAuthenticationFilter.class)
.authorizeHttpRequests((authorize) -> authorize
.requestMatchers("/css/**").permitAll()
.requestMatchers("/js/**").permitAll()
.requestMatchers("/images/**").permitAll()
.requestMatchers("/user/**").hasRole("USER")
.requestMatchers("/admin/**").hasRole("ADMIN")
.anyRequest().authenticated()
)
.formLogin(form -> form
.loginPage("/login") // LoginController経由でカスタマイズのログイン画面表示
.loginProcessingUrl("/login")
.permitAll()
);
return http.build();
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Bean
public AuthenticationManager authenticationManager(AuthenticationConfiguration authenticationConfiguration) throws Exception {
return authenticationConfiguration.getAuthenticationManager();
}
@Bean
public CompanyIdUsernamePasswordAuthenticationFilter companyIdUsernamePasswordAuthenticationFilter
(AuthenticationManager authenticationManager,
AuthenticationSuccessHandler authenticationSuccessHandler,
AuthenticationFailureHandler authenticationFailureHandler) {
return new CompanyIdUsernamePasswordAuthenticationFilter(authenticationManager,
authenticationSuccessHandler,
authenticationFailureHandler);
}
@Bean
public AuthenticationProvider authenticationProvider(PasswordEncoder passwordEncoder, LoginService loginService) {
CompanyIdUsernamePasswordAuthenticationProvider companyIdUsernamePasswordAuthenticationProvider
= new CompanyIdUsernamePasswordAuthenticationProvider();
companyIdUsernamePasswordAuthenticationProvider.setPasswordEncoder(passwordEncoder);
companyIdUsernamePasswordAuthenticationProvider.setLoginService(loginService);
return companyIdUsernamePasswordAuthenticationProvider;
}
@Bean
public AuthenticationSuccessHandler authenticationSuccessHandler() {
return (request, response, exception) -> {
response.sendRedirect("/success");
};
}
@Bean
public AuthenticationFailureHandler authenticationFailureHandler() {
return (request, response, exception) -> {
response.sendRedirect("/login?error");
};
}
}
参考サイト
SpringSecurity6の理解の助けに
SpringSecurityは6系にアップデートされてから書き方が大きく変わり、それ以降の情報が少なかったため、設定ファイルに関する書き方部分はこのスライドがとても参考になった。
また、Filter, Provider, managerなどの関係性やアーキテクチャーの部分が非常にわかりやすく解説されており、SpringSecurityの根本の理解の助けになった。
Filter, Token, Providerの実装の参考に
今回実装した要件と同じような、「会社ID」「ユーザーID」「パスワード」の3項目による認証のサンプルが載っている。
ただし、SpringFrameworkであったため、SpringBootとは設定ファイルの書き方が異なる。
また、このサンプルでは「ユーザーIDをもとにデータを取得した後に会社IDが正しいかどうかを検証する」プロセスだったため、「会社IDとユーザーIDでデータを取得した後パスワードを検証する」という今回の要件とは異なり、サイトの情報をそのまま活かす形とはいかなかった。
ただ、Filter、Token、Providerをどのように実装すればよいのかこのサイトのサンプルを参考にさせていただいた。
認証成功後に成功画面に遷移しなかった際の参考に
Filter, Token, Providerを実装した後に、なぜか認証自体は成功しているのに、ログイン認証成功後の画面に遷移しないという現象が起きた。
デバッグしているとカスタマイズしたFilterで認証が通った後にAnonymousAuthenticationFilter
というフィルターで未認証の判定になっているようだった。
その原因を色々調べていると上記記事にたどり着いた。
とりあえず結論としてはFilter内でsuccessfulAuthentication
メソッドをオーバーライドして、そこでセッションに保存するように実装すると正しく画面遷移されるようになった。
まとめと余談
個人的にSpringSecurityを使用したことはあったものの、その時はユーザーIDとパスワードの2項目で認証をしており、それほど難しくなかった。
3項目に増やすことで思った以上にカスタマイズする部分が増え、それだけでかなり難易度が上がったような印象だった。
とはいえ、いろいろと試しているうちにSpringSecurityのカスタマイズ性の高さがわかり、使いこなせるとかなり便利であることも実感できた。
ログイン認証成功時の処理や、ログアウト認証成功時の処理などもすべてインターフェースが容易されており、適宜クラスを作成することで適した処理を実装できるのはかなり楽だった。
サンプルでは扱っていないが、今回関わった案件では、ログイン成功時にユーザーの権限によってデフォルトで表示する画面を分けたり、ログイン時にログイン最終日時のカラムを更新するなどの要件もあった。
これらの実装もインターフェースを実装することでスムーズに実現することができた。
権限による制御も多くあるシステムだが、アクセス制御や非表示の制御もSpringSecurityで簡単に実現できた。
SpringSecurityは最初こそ学習コストが高いと感じたが、ある程度理解できてくると導入のメリットは大きいと思う。
余談
ユーザーIDとパスワードの3項目だけでログイン認証を実装する場合、Filter, Token, Providerなどのカスタマイズは不要で、 UserDetails、UserDetailsServiceの実装で実現することができた。
3つの項目による認証を実現する際、最初に試した方法はFilterで会社IDをセッションに保存し、UserDetailsServiceでセッションから情報を取得してProviderはデフォルトのものを使用して認証するというものだった。
結果、認証ができたように見えたが、ログイン後の画面遷移がうまく機能しなかったので、Providerを使った実装に変更した。
今考えると、そのケースでもAnonymousAuthenticationFilter
が原因でログイン後の画面に遷移していなかったように思う。
そうだとしたら、FilterとUserDetailsServiceを実装する形で実現できたかもしれない。
今後似たような要件があった場合は試してみようかと思う。