Spring Security + JWTを用いたAPI認証

# はじめに
　自主学習の中で認証機能やJWTについて学んだので、そのアウトプットとして認証機能を持たせたJavaアプリケーションを作成いたしました。
 　今回は、2つの実装方法で認証機能を実現いたしました。全てを説明すると冗長・重複する部分があるため、特に重要な箇所に注目して説明いたします。
　**※2つの実装方法共に自己署名JWTを使用しています。認可サーバー等の設定については本記事では取り上げませんので、ご了承ください。**

# リポジトリ

https://github.com/be834194/authTest-token-filter

https://github.com/be834194/authTest-token-OAuth

## JWTについて
　クライアント-サーバ間でセキュリティ情報の共有に使用されるオープンスタンダード。ドットで区切られた3つの文字列で構成され、それぞれbase64でエンコードされている。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2448205/c6bfab2f-ddd6-4bb7-b537-b75c363a8577.png)

- ヘッダ：署名に使用するトークンの種類とアルゴリズムを指定する
- ペイロード：送信するJSONデータの項目(**クレーム**と呼ばれる)の集まり
- 署名：ヘッダ.ペイロードを指定のアルゴリズムと秘密鍵で署名したもの

https://www.akana.com/blog/what-is-jwt

## securityFilterChain
 　Spring Security5.7以降では、WebSecurityConfigurerAdapterを継承する設定方法ではなく、securityFilterChainをbean登録してセキュリティ機能を用意する必要があります。

  https://spring.io/blog/2022/02/21/spring-security-without-the-websecurityconfigureradapter

# 認証, 認可以外の共通部分
　DBにpostgres, DB操作にはMybatisを使用しています。詳細なソースコードはgithubをご確認ください。

　アカウント登録(POST /account)は全てのユーザに許可して、アカウント取得(GET /account/{userName})は認証を受けたユーザがリクエストできるようにします。
![authTest-token-core -1.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2448205/1eb92f17-6ad3-41d7-b134-e45adaacb3fd.png)

　資格情報を提供するUserDetailsの実装クラスなども共通部分として使用します(Spring Securityの認証処理の仕組み等は、この記事では説明を省略いたします)。
![authTest-token-core -2.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2448205/87870a5d-3e20-4c4a-bdf2-449afce16dca.png)

## 共通部分のライブラリ

```pom.xml
		<dependency>
			<groupId>org.springframework.boot</groupId>
			<artifactId>spring-boot-starter-security</artifactId>
		</dependency>
		<dependency>
			<groupId>org.springframework.boot</groupId>
			<artifactId>spring-boot-starter-web</artifactId>
		</dependency>
		<dependency>
			<groupId>org.mybatis.spring.boot</groupId>
			<artifactId>mybatis-spring-boot-starter</artifactId>
			<version>3.0.4</version>
		</dependency>
        <dependency>
			<groupId>org.postgresql</groupId>
			<artifactId>postgresql</artifactId>
			<scope>runtime</scope>
		</dependency>
```

# Filterクラス + JWTを用いた認証
　securityFilterChainに、独自に実装したフィルタークラスを設定して認証, 認可機能を実現する方法を説明いたします。

## 追加ライブラリ

```pom.xml
		<dependency>
			<groupId>com.auth0</groupId>
			<artifactId>java-jwt</artifactId>
			<version>4.4.0</version>
		</dependency>
```

### JwtProducer
　JWTの生成と検証を行うクラスです。createTokenメソッドではユーザ名をクレームとして設定し、生成したトークンを返します。verifyTokenメソッドではヘッダーに記載されたアルゴリズムやトークンのクレーム内容について検証を行います。

```JwtProducer.java
package com.test.auth;

import java.util.Date;

import com.auth0.jwt.JWT;
import com.auth0.jwt.JWTVerifier;
import com.auth0.jwt.algorithms.Algorithm;
import com.auth0.jwt.exceptions.JWTCreationException;
import com.auth0.jwt.exceptions.JWTVerificationException;
import com.auth0.jwt.interfaces.DecodedJWT;

import org.springframework.stereotype.Component;

@Component
public class JwtProducer {

    public String createToken(String userName) {
        try {
            Date expireTime = new Date();
            expireTime.setTime(expireTime.getTime() + 600000l);

            Algorithm algorithm = Algorithm.HMAC256("secret");

            //JWT作成...クレームや有効期限などを設定
            String token = JWT.create()
                    .withIssuer("auth0") //トークン発行者情報
                    .withSubject("any token name") //トークンの主体
                    .withClaim("userName",userName)
                    .withExpiresAt(expireTime) //有効期間終了時間
                    .sign(algorithm); //作成したJWTに指定アルゴリズムで署名
            return token;
        } catch (JWTCreationException exception){
            return null;
        }
    }

    public DecodedJWT verifyToken(String token) {
        try {
            Algorithm algorithm = Algorithm.HMAC256("secret");

            //トークン署名の検証クラスを作成
            JWTVerifier verifier = JWT.require(algorithm)
                    .withIssuer("auth0")//トークンから復元したクレーム値が同じかをチェックする
                    .build();

            //指定されたトークンに対して検証を実施
            DecodedJWT jwt = verifier.verify(token);
            return jwt;
        } catch (JWTVerificationException exception){
            exception.printStackTrace();
            return null;
        }
    }
}
```

### AuthenticationFilter
　認証処理を行うFilterクラスです。[UsernamePasswordAuthenticationFilter](https://spring.pleiades.io/spring-security/site/docs/current/api/org/springframework/security/web/authentication/UsernamePasswordAuthenticationFilter.html)を継承し、認証処理や認証成功時の動作を設定しています。今回は、認証成功時のレスポンスヘッダーに「x-auth-token」を追加してJWTを返しています。
```AuthenticationFilter.java
package com.test.auth.config;

import com.fasterxml.jackson.databind.ObjectMapper;
import com.test.auth.JwtProducer;
import com.test.auth.entity.UserDetailsImpl;
import com.test.auth.form.AccountForm;

import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;

import java.io.IOException;
import java.util.ArrayList;

import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import org.springframework.security.web.util.matcher.AntPathRequestMatcher;
import org.springframework.stereotype.Component;

@Component
public class AuthenticationFilter extends UsernamePasswordAuthenticationFilter {

    private JwtProducer jwtProducer;

    public AuthenticationFilter(AuthenticationManager authenticationManager, JwtProducer jwtProducer) {
        
        setRequiresAuthenticationRequestMatcher(new AntPathRequestMatcher("/login", "POST"));
        setUsernameParameter("userName");
        setPasswordParameter("password");

        this.jwtProducer = jwtProducer;
        this.setAuthenticationManager(authenticationManager);
    }
    
    @Override
    public Authentication attemptAuthentication(HttpServletRequest req,
                                                HttpServletResponse res) throws AuthenticationException {
        try {
            AccountForm accountForm = new ObjectMapper().readValue(req.getInputStream(), AccountForm.class);

            return this.getAuthenticationManager().authenticate(
                new UsernamePasswordAuthenticationToken(accountForm.getUserName(), accountForm.getPassword(), new ArrayList<>())
            );
        } catch (IOException e) {
            e.printStackTrace();
            throw new RuntimeException(e);
        }
    }

    @Override
    protected void successfulAuthentication(HttpServletRequest req,
                                            HttpServletResponse res,
                                            FilterChain chain,
                                            Authentication auth) throws IOException, ServletException {
        String token = jwtProducer.createToken(((UserDetailsImpl)auth.getPrincipal()).getUsername());
        res.addHeader("x-auth-token",token);
    }
}
```

### AuthorizeFilter
　認可処理を行うFilterクラスです。POST /loginと POST /account以外の認証が必要なリクエストを受けた場合に、リクエストヘッダー「x-auth-token」内のJWTを検証します。

```AuthorizeFilter.java
package com.test.auth.config;

import com.auth0.jwt.interfaces.DecodedJWT;
import com.test.auth.JwtProducer;

import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;

import java.io.IOException;
import java.util.ArrayList;

import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.web.util.matcher.AntPathRequestMatcher;
import org.springframework.security.web.util.matcher.RequestMatchers;
import org.springframework.security.web.util.matcher.RequestMatcher;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;

@Component
public class AuthorizeFilter extends OncePerRequestFilter {

    private JwtProducer jwtProducer;

    private RequestMatcher anyMatcher = RequestMatchers.anyOf(new AntPathRequestMatcher("/login", "POST"),
                                                              new AntPathRequestMatcher("/account", "POST"));
    
    public AuthorizeFilter(JwtProducer jwtProducer) {
        this.jwtProducer = jwtProducer;
    }

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
        if (!anyMatcher.matches(request)) {
            String xAuthToken = request.getHeader("x-auth-token");
            if (xAuthToken == null || !xAuthToken.startsWith("Bearer ")) {
                filterChain.doFilter(request, response);
                return;
            }
            // tokenの検証と認証
            DecodedJWT decodedJWT = jwtProducer.verifyToken(xAuthToken.substring(7));
            String username = decodedJWT.getClaim("userName").toString();
            SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken(username, null, new ArrayList<>()));
        }
        filterChain.doFilter(request, response);
    }
}

```

### Bean登録
　認証処理を実行するためのAuthenticationManagerを新たにBean登録します。また、作成したFilterクラスをsecurityFilterChainに設定します。

```BeanConfig.java
package com.test.auth.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.ProviderManager;
import org.springframework.security.authentication.dao.DaoAuthenticationProvider;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;

@Configuration
public class BeanConfig {

    @Bean
	PasswordEncoder passwordEncoder() {
		return new BCryptPasswordEncoder();
	}

    @Bean
    AuthenticationManager authenticationManager(UserDetailsService userDetailsService, PasswordEncoder passwordEncoder) {
        DaoAuthenticationProvider authenticationProvider = new DaoAuthenticationProvider();
        authenticationProvider.setUserDetailsService(userDetailsService);
        authenticationProvider.setPasswordEncoder(passwordEncoder);
        return new ProviderManager(authenticationProvider);
    }
}

```

```WebSecurityConfig.java
package com.test.auth.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;

@Configuration
@EnableWebSecurity
@EnableMethodSecurity
public class WebSecurityConfig {

    private final AuthenticationFilter authenticationFilter;

    private final AuthorizeFilter authorizeFilter;

    private final AuthenticationManager authenticationManager;

    public WebSecurityConfig(AuthenticationFilter authenticationFilter,
                             AuthorizeFilter authorizeFilter,
                             AuthenticationManager authenticationManager){
        this.authenticationFilter = authenticationFilter;
        this.authorizeFilter = authorizeFilter;
        this.authenticationManager = authenticationManager;
    }

    @Bean
    SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http.csrf((csrf) -> csrf.disable());
        http.authorizeHttpRequests((requests) -> requests
                .requestMatchers( "/login", "/account").permitAll()
                .anyRequest().authenticated());
        http.authenticationManager(authenticationManager);
        http.addFilter(authenticationFilter)
            .addFilterBefore(authorizeFilter, UsernamePasswordAuthenticationFilter.class);
        return http.build();
    }

}

```

## 動作確認
　POSTMANを使ってREST APIの動作確認を行います。適当なユーザをPOST /accountで登録後にPOST /loginを実行すると、レスポンスヘッダー「x-auth-token」にJWTが入っています。
 ![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2448205/cd5d5b97-3f16-4367-a045-677874cf67ee.png)

　ログイン処理で入手した「x-auth-token」をリクエストヘッダーに設定してGET /account/{userName}を実行すると、正しくデータ取得ができます。
 ![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2448205/3e3ba69a-706f-4012-8869-0e2400134523.png)

 　GET /account/{userName}でx-auth-tokenを指定しない、x-auth-tokenの値を一部変更した、トークンの有効期限が切れた場合は403エラーになります。
 ![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2448205/98f77653-c3e2-4aa8-ab35-f3978b15c55e.png)
```
com.auth0.jwt.exceptions.SignatureVerificationException: The Token's Signature resulted invalid when verified using the Algorithm: HmacSHA256      
        at com.auth0.jwt.algorithms.HMACAlgorithm.verify(HMACAlgorithm.java:57)
        at com.auth0.jwt.JWTVerifier.verify(JWTVerifier.java:463)
        at com.auth0.jwt.JWTVerifier.verify(JWTVerifier.java:445)
        at com.test.auth.JwtProducer.verifyToken(JwtProducer.java:66)
```

# Springの提供ライブラリ + JWTを用いた認証
　独自に実装したフィルタークラス等は使わずに、Spring側で用意されているライブラリで認証, 認可機能を実現いたします。下記のhow toをなぞって実装しているので、細かな説明はそちらをご覧いただければと思います。
　ザックリとした実装の流れは以下の通りです。
1. 公開鍵と秘密鍵のペアを作成
2. oauth2ResourceServerを設定
3. JwtEncoderとJwtDecoderをBean登録
4. トークンを生成するエンドポイントを作成
 
 https://www.danvega.dev/blog/spring-security-jwt

## 追加ライブラリ
```pom.xml
		<dependency>
			<groupId>org.springframework.boot</groupId>
			<artifactId>spring-boot-starter-oauth2-authorization-server</artifactId>
		</dependency>
		<dependency>
			<groupId>org.springframework.boot</groupId>
			<artifactId>spring-boot-configuration-processor</artifactId>
			<optional>true</optional>
		</dependency>
```

### Bean登録
　HttpSecurityのメソッド「oauth2ResourceServer」を呼び出し、リソースサーバーの構成を行います。戻り値のOAuth2ResourceServerConfigurerクラスがBearerTokenAuthenticationFilterを接続し、これを使用してベアラートークンの要求を解析して認証を試行できるようです。

```WebSecurityConfig.java
package com.test.auth.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.oauth2.jwt.JwtDecoder;
import org.springframework.security.oauth2.jwt.JwtEncoder;
import org.springframework.security.oauth2.jwt.NimbusJwtDecoder;
import org.springframework.security.oauth2.jwt.NimbusJwtEncoder;
import org.springframework.security.web.SecurityFilterChain;

import com.nimbusds.jose.jwk.JWK;
import com.nimbusds.jose.jwk.JWKSet;
import com.nimbusds.jose.jwk.RSAKey;
import com.nimbusds.jose.jwk.source.ImmutableJWKSet;
import com.nimbusds.jose.jwk.source.JWKSource;
import com.nimbusds.jose.proc.SecurityContext;

@Configuration
@EnableWebSecurity
public class WebSecurityConfig {

    private final RsaKeyProperties rsaKeys;

    public WebSecurityConfig(RsaKeyProperties rsaKeys){
        this.rsaKeys = rsaKeys;
    }

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        return http
                .csrf(csrf -> csrf.disable())
                //OAuth2.0リソースサーバーのサポートを構成
                .oauth2ResourceServer(oauth2ResourceServer ->
                                        oauth2ResourceServer.jwt((jwt) ->
                                                      jwt.decoder(jwtDecoder())))
                .authorizeHttpRequests(auth -> 
                                        auth.requestMatchers( "/account").permitAll()
                                            .anyRequest().authenticated())
                .sessionManagement(session -> 
                                    session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
                .httpBasic(Customizer.withDefaults())
                .build();
    }

    @Bean
    //公開鍵を使用して解析する
    JwtDecoder jwtDecoder() {
        return NimbusJwtDecoder.withPublicKey(rsaKeys.publicKey()).build();
    }

    @Bean
    //秘密鍵を使用して署名する
    JwtEncoder jwtEncoder() {
        JWK jwk = new RSAKey.Builder(rsaKeys.publicKey()).privateKey(rsaKeys.privateKey()).build();
        JWKSource<SecurityContext> jwks = new ImmutableJWKSet<>(new JWKSet(jwk));
        return new NimbusJwtEncoder(jwks);
    }
}
```

https://spring.pleiades.io/spring-security/site/docs/current/api/org/springframework/security/config/annotation/web/builders/HttpSecurity.html#oauth2ResourceServer(org.springframework.security.config.Customizer)

### AuthController 
　フィルタークラスを使ったAPI認証ではPOST /loginでJWTを取得していましたが、今回はPOST /tokenでレスポンスボディからJWTを取得します。

```AuthController.java
package com.test.auth.controller;

import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RestController;

import com.test.auth.service.TokenService;

@RestController
public class AuthController {

    private final TokenService tokenService;

    public AuthController(TokenService tokenService) {
        this.tokenService = tokenService;
    }

    @PostMapping("/token")
    public String createToken(Authentication authentication) {
        String token = tokenService.generateToken(authentication);
        return token;
    }
}

```

### TokenService

```TokenService
package com.test.auth.service;

import org.springframework.security.core.Authentication;
import org.springframework.security.oauth2.jwt.JwtClaimsSet;
import org.springframework.security.oauth2.jwt.JwtEncoder;
import org.springframework.security.oauth2.jwt.JwtEncoderParameters;
import org.springframework.stereotype.Service;

import java.time.Instant;
import java.time.temporal.ChronoUnit;

@Service
public class TokenService {

    private final JwtEncoder encoder;

    public TokenService(JwtEncoder encoder) {
        this.encoder = encoder;
    }

    public String generateToken(Authentication authentication){
        Instant now = Instant.now();

        JwtClaimsSet claims = JwtClaimsSet.builder()
                .issuer("oauth2")
                .issuedAt(now)
                .expiresAt(now.plus(1, ChronoUnit.HOURS))
                .subject("any token name")
                .claim("userName", authentication.getName())
                .build();
        
        return this.encoder.encode(JwtEncoderParameters.from(claims)).getTokenValue();
    }
}
```

## 動作確認
　POST /tokenでレスポンスボディからJWTを取得します。POSTMANのAuthorizationタブのAuth Typeを「Basic Auth」にして、DBに登録してあるユーザ情報を入力してリクエストを送ります。すると、レスポンスとしてJWTが返ってきます。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2448205/840995e7-f941-4e0c-882b-4b4026bf3cbd.png)

　Auth Typeを「Bearea Token」に変更し、受け取ったJWTを入力してリクエストを送ると、正しくデータが取得できます。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2448205/4cee28b7-7296-4a30-b732-461542067f0e.png)

　Tokenを指定しない、Tokenの値を一部変更した、トークンの有効期限が切れた場合はUnauthorizedとなります。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2448205/56a5e5e1-c171-48d3-a6b0-d99bb7f9e2de.png)

# 参考文献
https://qiita.com/asagohan2301/items/cef8bcb969fef9064a5c

https://qiita.com/nyasba/items/f9b1b6be5540743f8bac