2
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Spring Security + JWTを用いたAPI認証

Last updated at Posted at 2025-02-24

はじめに

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

リポジトリ

JWTについて

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

image.png

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

securityFilterChain

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

認証, 認可以外の共通部分

 DBにpostgres, DB操作にはMybatisを使用しています。詳細なソースコードはgithubをご確認ください。

 アカウント登録(POST /account)は全てのユーザに許可して、アカウント取得(GET /account/{userName})は認証を受けたユーザがリクエストできるようにします。
authTest-token-core -1.png

 資格情報を提供するUserDetailsの実装クラスなども共通部分として使用します(Spring Securityの認証処理の仕組み等は、この記事では説明を省略いたします)。
authTest-token-core -2.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を継承し、認証処理や認証成功時の動作を設定しています。今回は、認証成功時のレスポンスヘッダーに「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

 ログイン処理で入手した「x-auth-token」をリクエストヘッダーに設定してGET /account/{userName}を実行すると、正しくデータ取得ができます。
image.png

 GET /account/{userName}でx-auth-tokenを指定しない、x-auth-tokenの値を一部変更した、トークンの有効期限が切れた場合は403エラーになります。
image.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. トークンを生成するエンドポイントを作成

追加ライブラリ

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);
    }
}

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

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

 Auth Typeを「Bearea Token」に変更し、受け取ったJWTを入力してリクエストを送ると、正しくデータが取得できます。
image.png

 Tokenを指定しない、Tokenの値を一部変更した、トークンの有効期限が切れた場合はUnauthorizedとなります。
image.png

参考文献

2
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?