- フロントエンド全体がSPA(Single Page Application)で構成されており、バックエンドがREST APIのみで構成されているアプリケーションに、Spring Securityを用いた認証機能を導入してみたので、方法を紹介します。(今回はバックエンド側のみの対応となります。)
- Spring Securityにはフォームベース認証をデフォルトで提供していますが、今回はREST APIを用いた認証を行うため、JSON Web Token(JWT) を用いたカスタム認証を実装します。
- 従来と比べて記法が大きく変わったSpring Security6.0〜の機能を用いて実装します
Spring Securityについて
- Spring Securityとは、認証・認可及び、CSRF対策やHTTP Security Headerの付与を通して一般的な攻撃に対する保護を提供するフレームワークです。
- サーブレットフィルターの仕組みを利用して、リクエストごとに必要なSecurityFilterを実行することで、セキュリティ対策を実現します。
実装の詳細と説明
依存関係の追加
build.gradleのdependenciesに下記を追加します。
implementation 'org.springframework.boot:spring-boot-starter-security:3.1.1'
SecurityConfig.java
- セキュリティに関する設定を行うJavaConfigです。
import com.example.springbootdemo.security.AuthorizeFilter;
import com.example.springbootdemo.security.LoginUserDetailsService;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.dao.DaoAuthenticationProvider;
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.web.cors.CorsConfiguration;
import org.springframework.web.cors.CorsConfigurationSource;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
// CORSの設定を有効化...(1)
http.cors().configurationSource(this.corsConfigurationSource());
// csrf設定を無効化。...(2)
http.csrf().disable();
// (3)
http.authorizeHttpRequests((requests) -> requests
.requestMatchers( "/login").permitAll()
.anyRequest().authenticated());
http.addFilterBefore(new AuthorizeFilter(), UsernamePasswordAuthenticationFilter.class);
return http.build();
}
// (4)
@Bean
public PasswordEncoder passwordEncoder(){
return new BCryptPasswordEncoder();
}
// (5)
@Bean
public DaoAuthenticationProvider authenticationProvider(LoginUserDetailsService loginUserDetailsService) {
DaoAuthenticationProvider provider = new DaoAuthenticationProvider();
provider.setUserDetailsService(loginUserDetailsService);
provider.setPasswordEncoder(passwordEncoder());
return provider;
}
// CORSの設定
private CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration corsConfiguration = new CorsConfiguration();
corsConfiguration.addAllowedMethod(CorsConfiguration.ALL);
corsConfiguration.addAllowedHeader(CorsConfiguration.ALL);
// クロスドメインのリクエストに対してX-AUTH-TOKENヘッダーでトークンを返すように設定しています。
corsConfiguration.addExposedHeader("X-AUTH-TOKEN");
corsConfiguration.addAllowedOrigin("http://front-origin.example.com");
corsConfiguration.setAllowCredentials(true);
UrlBasedCorsConfigurationSource corsSource = new UrlBasedCorsConfigurationSource();
corsSource.registerCorsConfiguration("/**", corsConfiguration);
return corsSource;
}
}
- (1)
this.corsConfigurationSource()
で設定されたオリジンに対してCORSの設定を有効化しています。 - (2)Spring SecurityではデフォルトでCSRF対策が有効になっています。
CSRF対策が有効な場合、POSTリクエストにはCSRFトークンが必要です。
指定したパスがCSRF対策の対象外である場合、リクエストにCSRFトークンが含まれていないため、403エラーが返される可能性があります。
この場合、CSRF対策を無効化するか、リクエストにCSRFトークンを含める必要があります。
ここではサンプルアプリケーションのためhttp.csrf().disable();
として設定を無効化しています。 - (3)loginエンドポイントへの認証なしアクセスを許可し、他の全てのエンドポイントに認証つきアクセスを要求します。
AuthorizeFilter
は、/login
エンドポイント以外のAPIアクセス時に認証処理を行うカスタムフィルターです(実装は後述)。 - (4)パスワードをハッシュ化して保存するためのクラス。
BCryptPasswordEncoder
は、bcryptアルゴリズムを使用してパスワードをエンコードします。 - (5)
DaoAuthenticationProvider
は、UserDetailsService
を使用してユーザー情報を取得し、パスワードの照合を行うための認証プロバイダーです。
LoginUserDetailsService
を使用してユーザー情報を取得し、PasswordEncoder
を使用してパスワードの照合を行うように設定しています。
AuthorizeFilter.java
- 認証が必要なAPIアクセス時に認証処理を行うFilterクラスです。
- 継承元の
OncePerRequestFilter
は、Spring Frameworkで提供されるフィルターの1つで、リクエストごとに1回だけ実行されることを保証するフィルターです。
import com.auth0.jwt.JWT;
import com.auth0.jwt.algorithms.Algorithm;
import com.auth0.jwt.interfaces.DecodedJWT;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.web.util.matcher.AntPathRequestMatcher;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
@Component
public class AuthorizeFilter extends OncePerRequestFilter {
private final AntPathRequestMatcher matcher = new AntPathRequestMatcher("/login");
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
if (!matcher.matches(request)) {
// headersのkeyを指定してトークンを取得します
String xAuthToken = request.getHeader("X-AUTH-TOKEN");
if (xAuthToken == null || !xAuthToken.startsWith("Bearer ")) {
filterChain.doFilter(request, response);
return;
}
// tokenの検証と認証
DecodedJWT decodedJWT = JWT.require(Algorithm.HMAC256("__secret__")).build().verify(xAuthToken.substring(7));
// usernameの取得
String username = decodedJWT.getClaim("username").toString();
// ログイン状態の設定
SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken(username, null, new ArrayList<>()));
}
filterChain.doFilter(request, response);
}
}
LoginUserDetails.java
- Spring Securityの
UserDetails
インターフェースを実装するLoginUserDetails
クラスです。 - このクラスは、ユーザーの認証情報を保持するために使用されます。
-
UserDetails
インターフェースは、Spring Securityによって提供される認証機能を使用するために必要なメソッドを定義しています。 - 今回はサンプルアプリのため
getUsername()
・getPassword()
のみ実装しています。
import com.example.springbootdemo.entity.UserEntity;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
@Getter
@RequiredArgsConstructor
public class LoginUserDetails implements UserDetails {
private final UserEntity user;
// ユーザーが持つ権限を返す
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return new ArrayList<>();
}
@Override
public String getPassword() {
return user.getPassword();
}
@Override
public String getUsername() {
return user.getUserName();
}
// ユーザーアカウントが有効期限切れでないかを判定する
@Override
public boolean isAccountNonExpired() {
return true;
}
// ユーザーアカウントがロックされていないかを判定する
@Override
public boolean isAccountNonLocked() {
return true;
}
// 認証情報が有効期限切れでないかを判定する
@Override
public boolean isCredentialsNonExpired() {
return true;
}
@Override
public boolean isEnabled() {
return true;
}
}
LoginUserDetailsService.java
- ユーザー名を受け取り、そのユーザー名に対応する
UserEntity
をデータベースから取得し、それをLoginUserDetails
オブジェクトに変換して返します。
import com.example.springbootdemo.entity.UserEntity;
import com.example.springbootdemo.repository.UserRepository;
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;
@Service
@RequiredArgsConstructor
public class LoginUserDetailsService implements UserDetailsService {
private final UserRepository repository;
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
Optional<UserEntity> maybeUser = Optional.of(repository.findByName(username));
return maybeUser.map(LoginUserDetails::new)
.orElseThrow(() -> new UsernameNotFoundException("username not found."));
}
}
UserEntity.java
- ユーザー情報を保持するEntity
@Value
public class UserEntity {
Integer id;
String userName;
String password;
}
UserRepository.java
- ユーザー情報を取得するRepository
import com.example.springbootdemo.entity.UserEntity;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Select;
@Repository
@Mapper
public interface UserRepository {
@Select("SELECT * FROM Users WHERE userName = #{userName}")
UserEntity findByName(String userName);
}
LoginController.java
- Spring Securityを使用してユーザーの認証を行い、JWTトークンを生成してクライアントに返すためのREST APIエンドポイント
- ここではControllerに実装しましたが、認証機能をカスタムSecurity Filterに持たせるようにした方が望ましい気がします
import com.auth0.jwt.JWT;
import com.auth0.jwt.algorithms.Algorithm;
import com.example.springbootdemo.controller.form.UserForm;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.AuthenticationException;
@RestController
@CrossOrigin
public class LoginController {
@Autowired
private DaoAuthenticationProvider daoAuthenticationProvider;
@RequestMapping(value = "/login",method = RequestMethod.POST)
public ResponseEntity<String> login(@RequestBody UserForm request) {
try {
// DaoAuthenticationProviderを用いた認証を行う
daoAuthenticationProvider.authenticate(new UsernamePasswordAuthenticationToken(request.getUsername(), request.getPassword()));
// JWTトークンの生成
String token = JWT.create().withClaim("username",request.getUsername())
.sign(Algorithm.HMAC256("__secret__"));
// トークンをクライアントに返す
HttpHeaders httpHeaders = new HttpHeaders();
httpHeaders.add("x-auth-token",token);
return new ResponseEntity(httpHeaders, HttpStatus.OK);
} catch (AuthenticationException e) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
}
}
}
UserForm.java
- クライアントからユーザー情報を受け取るForm
@Getter
@Setter
public class UserForm {
private String username;
private String password;
}
まとめ
- 今回はSpring Boot × SPAで作成したアプリケーションに、 Spring Securityを用いた認証機能を導入する方法を試してみました。特に、バージョン6.0以降の記法が以前と大きく変わっており、注意が必要でした。また、アーキテクチャに関してはそこそこ難解なため、更なる勉強が必要だと感じました。
- 今回はAuth0の機能を用いてJWTトークンの生成および検証を行っていますが、Spring Security自体も
spring-security-oauth2-resource-server
及びspring-security-oauth2-jose
によってJWTを用いたBearer認証を実現する機能を提供しています。また機会があればこちらも使ってみたいと思います。