Spring Boot × SPAで作成したアプリケーションに、 Spring Securityを用いた認証機能を導入する


- フロントエンド全体が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を実行することで、セキュリティ対策を実現します。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2985558/7a65d46a-2ae8-c101-7aa6-2c2841ca19fe.png)

# 実装の詳細と説明
## 依存関係の追加
build.gradleのdependenciesに下記を追加します。
```groovy
implementation 'org.springframework.boot:spring-boot-starter-security:3.1.1'
```
## SecurityConfig.java
- セキュリティに関する設定を行うJavaConfigです。
```java
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回だけ実行されることを保証するフィルターです。
```java
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()`のみ実装しています。
```java
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`オブジェクトに変換して返します。
```java
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
```java
@Value
public class UserEntity {
    Integer id;
    String userName;
    String password;
}
```
## UserRepository.java
- ユーザー情報を取得するRepository
```java
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に持たせるようにした方が望ましい気がします
```java
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
```java
@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認証を実現する機能を提供しています。また機会があればこちらも使ってみたいと思います。

# 参考資料
- https://docs.spring.io/spring-security/reference/index.html
- https://www.docswell.com/s/MasatoshiTada/KGVY9K-spring-security-intro#p1
- https://zenn.dev/misaka/books/9734700544990d/viewer/dad770
- https://stackoverflow.com/questions/64960385/how-can-i-setup-login-with-spring-security-and-vue-js