はじめに
自主学習の中で認証機能やJWTについて学んだので、そのアウトプットとして認証機能を持たせたJavaアプリケーションを作成いたしました。
今回は、2つの実装方法で認証機能を実現いたしました。全てを説明すると冗長・重複する部分があるため、特に重要な箇所に注目して説明いたします。
※2つの実装方法共に自己署名JWTを使用しています。認可サーバー等の設定については本記事では取り上げませんので、ご了承ください。
リポジトリ
JWTについて
クライアント-サーバ間でセキュリティ情報の共有に使用されるオープンスタンダード。ドットで区切られた3つの文字列で構成され、それぞれbase64でエンコードされている。
- ヘッダ:署名に使用するトークンの種類とアルゴリズムを指定する
- ペイロード:送信するJSONデータの項目(クレームと呼ばれる)の集まり
- 署名:ヘッダ.ペイロードを指定のアルゴリズムと秘密鍵で署名したもの
securityFilterChain
Spring Security5.7以降では、WebSecurityConfigurerAdapterを継承する設定方法ではなく、securityFilterChainをbean登録してセキュリティ機能を用意する必要があります。
認証, 認可以外の共通部分
DBにpostgres, DB操作にはMybatisを使用しています。詳細なソースコードはgithubをご確認ください。
アカウント登録(POST /account)は全てのユーザに許可して、アカウント取得(GET /account/{userName})は認証を受けたユーザがリクエストできるようにします。
資格情報を提供するUserDetailsの実装クラスなども共通部分として使用します(Spring Securityの認証処理の仕組み等は、この記事では説明を省略いたします)。
共通部分のライブラリ
<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に、独自に実装したフィルタークラスを設定して認証, 認可機能を実現する方法を説明いたします。
追加ライブラリ
<dependency>
<groupId>com.auth0</groupId>
<artifactId>java-jwt</artifactId>
<version>4.4.0</version>
</dependency>
JwtProducer
JWTの生成と検証を行うクラスです。createTokenメソッドではユーザ名をクレームとして設定し、生成したトークンを返します。verifyTokenメソッドではヘッダーに記載されたアルゴリズムやトークンのクレーム内容について検証を行います。
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を返しています。
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を検証します。
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に設定します。
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);
}
}
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が入っています。
ログイン処理で入手した「x-auth-token」をリクエストヘッダーに設定してGET /account/{userName}を実行すると、正しくデータ取得ができます。
GET /account/{userName}でx-auth-tokenを指定しない、x-auth-tokenの値を一部変更した、トークンの有効期限が切れた場合は403エラーになります。
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をなぞって実装しているので、細かな説明はそちらをご覧いただければと思います。
ザックリとした実装の流れは以下の通りです。
- 公開鍵と秘密鍵のペアを作成
- oauth2ResourceServerを設定
- JwtEncoderとJwtDecoderをBean登録
- トークンを生成するエンドポイントを作成
追加ライブラリ
<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を接続し、これを使用してベアラートークンの要求を解析して認証を試行できるようです。
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を取得します。
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が返ってきます。
Auth Typeを「Bearea Token」に変更し、受け取ったJWTを入力してリクエストを送ると、正しくデータが取得できます。
Tokenを指定しない、Tokenの値を一部変更した、トークンの有効期限が切れた場合はUnauthorizedとなります。
参考文献