以下の記事で、API としてログイン処理をリクエストし、認証を行うところまで行きました。
この記事では、認証成功の後、後続の API 処理まで行う方法を説明します。
具体的には認証後に JWT を生成し、以降はその値を検証して認証されていることを確認します。なお、Spring Security に JWT 関連のクラスはあるが、Oauth用?みたいで使えそうにありませんでした。
また、以下の記事にあるように、HTTPでトークンを利用した認証認可をする手段は RFC で規定されていますが、今回やったのはそこまでは厳密ではなく、Authroization
ヘッダに JWT をセットし、その JWT をチェックして問題ない場合に API の実行を許可する、というものです。
前提
- Java 17
- Spring Boot 2.7.8
- Spring Security 5.7.6
コードベースは、冒頭の前の記事です。
実現方法
他の記事であったのは、認証は UsernamePasswordAuthenticationFilter
を利用し、認証成功後に JWT を生成して返却する。その後、OncePerRequestFilter
でヘッダ検証を行う方法でした。
それでも良いのでしょうが、Spring Security には、RequestHeaderAuthenticationFilter
という認証済みを示す Filter があるので、今回はそちらを利用。
イメージとしては、
- ログイン時は
UsernamePasswordAuthenticationFilter
でDaoAuthenticationProvider
を利用して認証を行う - ログイン後は
RequestHeaderAuthenticationFilter
でPreAuthenticatedAuthenticationProvider
を利用して HTTP Header から認証情報を取り出す。
実装
jwt ライブラリの追加
java-jwt を利用。
<dependency>
<groupId>com.auth0</groupId>
<artifactId>java-jwt</artifactId>
<version>4.2.2</version>
</dependency>
ログイン成功後に jwt を生成
認証成功後のハンドラ(setAuthenticationSuccessHandler
)でJWT を生成します。
@Slf4j
public class MyUsernamePasswordAuthenticationFilter extends UsernamePasswordAuthenticationFilter {
private final AuthenticationManager authenticationManager;
public MyUsernamePasswordAuthenticationFilter(AuthenticationManager authenticationManager) {
this.authenticationManager = authenticationManager;
// "/api/login" の場合に認証を行うよう設定
setRequiresAuthenticationRequestMatcher(new AntPathRequestMatcher("/api/login", "POST"));
var issuedAt = new Date();
this.setAuthenticationSuccessHandler((req, res, ex) -> {
// JWT の作成
String token = JWT.create()
.withIssuer("test-issuer")
// 発行日時
.withIssuedAt(issuedAt)
// 有効期限
.withExpiresAt(new Date(issuedAt.getTime() + 1000 * 60 * 60))
// ユーザ名
.withClaim("username", ex.getName())
// ロール情報
.withClaim("role", ex.getAuthorities().iterator().next().toString())
.sign(Algorithm.HMAC256("secret"));
res.setHeader("X-AUTH-TOKEN", token);
res.setStatus(200);
// ユーザ情報を返却
MyUserDetails user = (MyUserDetails) SecurityContextHolder.getContext().getAuthentication().getPrincipal();
res.getWriter().write((new ObjectMapper()).writeValueAsString(user.getPerson()));
});
}
...
認証チェック用の Filter の追加
今回は /api/hello
に対して認証チェックをするようにしています。チェックする HTTP ヘッダは Authorization
です。なお、setExceptionIfHeaderMissing
を false にすると、Authorization
ヘッダがない場合も例外は発生しません。
@Slf4j
public class MyRequestHeaderAuthenticationFilter extends RequestHeaderAuthenticationFilter {
public MyRequestHeaderAuthenticationFilter(AuthenticationManager authenticationManager) {
setPrincipalRequestHeader("Authorization");
setExceptionIfHeaderMissing(false);
setAuthenticationManager(authenticationManager);
setRequiresAuthenticationRequestMatcher(new AntPathRequestMatcher("/api/hello"));
this.setAuthenticationSuccessHandler((req, res, ex) -> {
log.info("Success 2");
});
this.setAuthenticationFailureHandler((req, res, ex) -> {
log.info("Failure 2");
});
}
}
認証チェック用の UserDetailService の追加
上の処理で setPrincipalRequestHeader
に Authorization
を設定したので、PreAuthenticatedAuthenticationToken
の principal に トークン文字列が入っています。
今回は bearer の文字に関しては割愛してますので、期待する形は「Authorization: <jwt>」です。
@Slf4j
@Service
public class MyAuthenticationUserDetailService implements AuthenticationUserDetailsService<PreAuthenticatedAuthenticationToken> {
@Override
public UserDetails loadUserDetails(PreAuthenticatedAuthenticationToken token) throws UsernameNotFoundException {
DecodedJWT decodedJWT;
try {
decodedJWT = JWT.require(Algorithm.HMAC256("secret")).build().verify(token.getPrincipal().toString());
} catch (JWTDecodeException ex) {
throw new BadCredentialsException("Authorization header token is invalid");
}
if (decodedJWT.getToken().isEmpty()) {
throw new UsernameNotFoundException("Authorization header must not be empty.");
}
// decode できた後は、jwt の情報からユーザ情報を作る
// getClaim 下戻りは Claim クラスなので、本来の値の取り出しには文字列なら asString() が必要。
Person person = new Person();
person.setUsername(decodedJWT.getClaim("username").asString());
person.setPassword("");
person.setRoles(decodedJWT.getClaim("role").asString());
return new MyUserDetails(person);
}
}
Provider の設定と、HttpSecurity へ Filter を追加
最後はこれまで用意したものをつなぎ合わせる部分です。
ここの実装方法は悩み試行錯誤しましたが、最終的には以下のようにするといけました。デフォルトでは、DaoAuthenticationProvider のみですが、今回は PreAuthenticatedAuthenticationProvider
も必要なので、両方を Provider として設定します。各々対応する UserDetailService
も合わせてセットします。
最後に、MyRequestHeaderAuthenticationFilter
を追加したら完成です。
なお、コード中の configureProvider
は、こちら に書かれている通り、メソッド名は何でもよいです。
@EnableWebSecurity
@Configuration
public class MySecurityConfig {
@Autowired
public void configureProvider(
AuthenticationManagerBuilder auth,
MyUserDetailsService myUserDetailsService,
MyAuthenticationUserDetailService myAuthenticationUserDetailService) throws Exception {
var preAuthenticatedAuthenticationProvider = new PreAuthenticatedAuthenticationProvider();
preAuthenticatedAuthenticationProvider.setPreAuthenticatedUserDetailsService(myAuthenticationUserDetailService);
preAuthenticatedAuthenticationProvider.setUserDetailsChecker(new AccountStatusUserDetailsChecker());
auth.authenticationProvider(preAuthenticatedAuthenticationProvider);
var daoAuthenticationProvider = new DaoAuthenticationProvider();
daoAuthenticationProvider.setUserDetailsService(myUserDetailsService);
daoAuthenticationProvider.setPasswordEncoder(new BCryptPasswordEncoder(8));
auth.authenticationProvider(daoAuthenticationProvider);
}
@Bean
public AuthenticationManager authenticationManager(AuthenticationConfiguration authenticationConfiguration) throws Exception {
return authenticationConfiguration.getAuthenticationManager();
}
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http.authorizeHttpRequests(authz -> authz
.antMatchers("/").permitAll()
.antMatchers("/api/login").permitAll()
.anyRequest().authenticated()
);
var authManager = authenticationManager(http.getSharedObject(AuthenticationConfiguration.class));
http.addFilter(new MyUsernamePasswordAuthenticationFilter(authManager));
http.addFilter(new MyRequestHeaderAuthenticationFilter(authManager));
// セッションを使用しない
http.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS);
http.csrf().disable();
return http.build();
}
動作確認
今回は Advanced REST Client を利用しています。
ログイン
ログイン & パスワードで認証します。x-auth-token
の HTTP ヘッダが返って来ているのが確認できます。
ちなみに、Spring Security が提供する ProviderManager の処理ですが、providers が2つになっていることが確認できます。
要認証 api へのアクセス(認証なし)の場合
チェックに失敗するので 403 が返ります。以下はヘッダがない場合ですが、Authorization
ヘッダがあるけど値が正しくない場合も 403 になります。
要認証 api へのアクセス(認証あり)の場合
Authorization
ヘッダに正しく JWT が設定されている場合は 200 でちゃんとレスポンスも返ってきました。
以下は MyAuthenticationUserDetailService.java
の loadUserDetails
の処理ですが、JWT が正しくデコードできて値が取れていることが確認できます。