背景
外部の認証基盤の利用を想定しているシステムを開発していくうえで、インフラ側の構築作業が間にあっていなかったり、諸事情でローカルでの開発ではインフラ側で利用する認証基盤にアクセスできない場合があると思います。そのような場合はローカルだけでJWTの発行とJWTによる認証認可を完結できるようにすると開発がスムーズになることがあると思います。本記事ではその実装方法について説明していきます。
本記事で想定している開発環境、本番環境のシステム構成は下の図の通りです。
前提
この記事ではフレームワークとしてはSpring Boot 3.xを使用し、認証認可にはSpring Security 6.xを利用する前提で説明します。認証基盤としてはCognitoを使用する想定ですが、JWTを使用した認証認可なら他の認証基盤を利用する場合でも問題なく動作すると思います。
実装の流れ
認証機能を実装するためには下記のものが必要になるので、それぞれ順番に実装していきます。
- JWTを発行する認証エンドポイント
- パスワード認証を行うために必要なAuthenticationManagerの準備
- JWTの作成・署名用メソッド
- 認証時の処理を定義するためのSecurityFilterChainの定義
- JWT検証に必要なJwtConverter
- JWT検証後の認可処理に必要なGrantedAuthority、AbstractAuthenticationToken
認証エンドポイントの実装
認証エンドポイントについては以下の通り。
@RestController
@RequiredArgsConstructor
@Profile("local")
public class LocalController {
private final TokenSigner tokenSigner;
private final AuthenticationManager authenticationManager;
private final Clock clock;
private final DataSourceScriptDatabaseInitializer dataSourceScriptDatabaseInitializer;
@PostMapping(path = "/token")
public Object issueToken(
@RequestBody IssueTokenRequest request,
UriComponentsBuilder builder) {
try {
// ユーザー認証
Authentication authenticated = authenticationManager.authenticate(
UsernamePasswordAuthenticationToken.unauthenticated(request.getUsername(),
request.getPassword()));
UserDetails userDetails = (UserDetails) authenticated.getPrincipal();
// JWTのclaimの設定
String issuer = builder.path("").build().toString();
Instant issuedAt = Instant.now(this.clock);
Instant expiresAt = issuedAt.plus(720, ChronoUnit.HOURS);
JWTClaimsSet claimsSet = new JWTClaimsSet.Builder()
.issuer(issuer)
.expirationTime(Date.from(expiresAt))
.subject(userDetails.getUsername())
.issueTime(Date.from(issuedAt))
.claim("username", userDetails.getUsername())
.build();
// 署名
String tokenValue = this.tokenSigner.sign(claimsSet).serialize();
return ResponseEntity.ok(Map.of(
"accessToken", tokenValue,
"tokenType", BEARER.getValue(),
"expiresIn", Duration.between(issuedAt, expiresAt).getSeconds()));
} catch (AuthenticationException e) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
.body(Map.of("error", "unauthorized",
"error_description", e.getMessage()));
}
}
}
ここではローカルでしか使用しないエンドポイントになるので@Profile('local')を付与し、localの環境のみで追加されるエンドポイントとして登録しています。また、生成するJWTに含めるデータ(JWTClaimsSet)を作成してJWTの作成・署名を行うメソッド(後述)に渡しています。
AuthenticationManagerの準備
認証基盤を利用する場合はアプリケーションのDBでパスワードを扱わないので、パスワード認証のために固定のユーザー名とパスワードで認証を行うAuthenticationManagerを別途準備します。
@Configuration
@Profile("local")
public class LocalAuthConfig {
private static final List<UserDetails> userDetailsList = List.of(
User.withUsername("local-user1").password("{noop}secret").build(),
User.withUsername("local-user2").password("{noop}secret").build()
);
@Bean
public AuthenticationManager authenticationManager(UserDetailsService userDetailsService,
PasswordEncoder passwordEncoder) {
DaoAuthenticationProvider authenticationProvider = new DaoAuthenticationProvider();
authenticationProvider.setUserDetailsService(userDetailsService);
authenticationProvider.setPasswordEncoder(passwordEncoder);
return new ProviderManager(authenticationProvider);
}
@Bean
public UserDetailsService userDetailsService() {
// 認証ユーザーが複数必要な場合、userDetailsListにユーザーを追加する。
return new InMemoryUserDetailsManager(userDetailsList);
}
@Bean
public PasswordEncoder passwordEncoder() {
return PasswordEncoderFactories.createDelegatingPasswordEncoder();
}
}
※ローカル上でのみ動くことを想定しているので、パスワードは平文{noop}でsecretという値を設定しています。
JWTの作成・署名メソッド実装
ユーザー名・パスワードによる認証ができるようになったので、次はJWTの作成と署名を行います。JWTの作成にはNimbus JWTを使用します。JWTClaimSetに必要な情報を設定後、Nimbus JOSEを使用して署名を行います。署名を行うTokenSignerクラスの実装は以下の通り。
@Component
@Profile("local")
public class TokenSigner implements InitializingBean {
private final JWSSigner signer;
private final JWSVerifier verifier;
public TokenSigner(JwtProperties jwtProperties) {
this.signer = new RSASSASigner(jwtProperties.privateKey());
this.verifier = new RSASSAVerifier(jwtProperties.publicKey());
}
public SignedJWT sign(JWTClaimsSet claimsSet) {
JWSHeader header = new JWSHeader.Builder(JWSAlgorithm.RS256)
.type(JOSEObjectType.JWT)
.build();
SignedJWT signedJwt = new SignedJWT(header, claimsSet);
try {
signedJwt.sign(this.signer);
} catch (JOSEException e) {
throw new IllegalStateException(e);
}
return signedJwt;
}
@Override
public void afterPropertiesSet() throws Exception {
JWTClaimsSet claimsSet = new JWTClaimsSet.Builder().subject("test").build();
SignedJWT signedJwt = sign(claimsSet);
if (!signedJwt.verify(this.verifier)) {
throw new IllegalStateException("The pair of public key and private key is wrong.");
}
}
}
署名側(signer)に秘密鍵を、検証側(verifier)に公開鍵を設定し、署名済みJWT(SignedJWT)を返すsignメソッドを実装します。このsignメソッドでJWTClaimsSetから署名済みJWTを生成し、最終的に認証エンドポイント(LocalController#issueToken)からレスポンスとして返すことになります。この実装で利用する公開鍵・秘密鍵に関してはopenssl等のツールを利用して作成し、APから参照できる場所に格納する必要があります。
SecurityFilterChainの定義
上の認証エンドポイントから取得された署名済みJWTが、Authorizationヘッダに
Bearer <署名済みJWT>
の形で設定され、リクエストを投げられることになります。リクエストヘッダに含まれるJWTの検証するにはSpring Securityを使用するので、SecurityFilterChainの定義を行います。
@Configuration
@EnableWebSecurity
@EnableMethodSecurity(prePostEnabled = true)
@RequiredArgsConstructor
public class WebSecurityConfig {
private final JwtConverter converter;
@Bean
SecurityFilterChain securityFilterChain(HttpSecurity http)
throws Exception {
http
.authorizeHttpRequests(authorize ->
authorize
.requestMatchers("/api/**").authenticated()
.anyRequest().permitAll())
.oauth2ResourceServer(customizer -> customizer.jwt(
jwtCustomizer -> {
var issuerUrl = env.getProperty(
"spring.security.oauth2.resourceserver.jwt.issuer-uri");
if (!StringUtils.isBlank(issuerUrl)) {
jwtCustomizer.jwkSetUri(issuerUrl + "/.well-known/jwks.json");
}
jwtCustomizer.jwtAuthenticationConverter(converter);
}));
return http.build();
}
}
application.properties(またはapplication.yaml)内でspring.security.oauth2.resourceserver.jwt.issuer-uriを設定する場合、設定したissuer-uri経由で取得したJWKSを使用してJWTの検証・デコードが行われます。今回は検証不要なので、if文でissuer-uriが定義されていない場合はJWKSを設定しないように実装します。
JWT検証に必要なJwtConverter実装
JWTにはシステムによって必要な情報を追加したりすると思うので、別途JWTのClaimから値を取り出すためのJwtConverterを実装します。
@Component
@RequiredArgsConstructor
public class JwtConverter implements Converter<Jwt, UserToken> {
private static final String CLAIM_KEY_ID = "sub";
private static final String CLAIM_KEY_USERNAME = "preferred_username";
private static final String CLAIM_KEY_ROLES = "roles";
private final UserRepository repository;
@Override
public UserToken convert(Jwt token) {
String id = token.getClaimAsString(CLAIM_KEY_ID);
String name = token.getClaimAsString(CLAIM_KEY_USERNAME);
User user = repository.findById(id);
// ユーザーが見つからなかった場合は認証失敗として処理する
if (user.isEmpty()) {
var userToken = new UserToken(authorities, null);
userToken.setAuthenticated(false);
return userToken;
}
Collection<UserAuthority> authorities = user.get()
.getRoles().stream()
.map(UserRole::getRoleName)
.map(UserAuthority::new).toList();
UserToken userToken = new UserToken(authorities, user.get());
userToken.setAuthenticated(true);
return userToken;
}
}
上記実装でJWTのClaimからユーザーIDを取得し、DBからUserRepository経由で認可に必要なアクセス権限等のユーザー情報を取得しています。
GrantedAuthority、AbstractAuthenticationTokenの実装
最後に、Spring Securityの認可においてGrantedAuthorityを実装したクラスを返す必要があるので、GrantedAuthorityを実装したUserAuthorityを実装します。
@RequiredArgsConstructor
public class UserAuthority implements GrantedAuthority {
private final String role;
@Override
public String getAuthority() {
return role;
}
}
また、Spring Securityの認証認可の仕組みに則るうえでAbstractAuthenticationTokenを継承したクラスのインスタンスをJwtConverter#convertの返り値として返す必要があるので、UserTokenとして実装します。
public class UserToken extends AbstractAuthenticationToken {
private final User user;
public UserToken(Collection<? extends GrantedAuthority> authorities,
User user) {
super(authorities);
this.user = user;
}
@Override
public User getPrincipal() {
return user;
}
@Override
public Object getCredentials() {
return null;
}
}
以上で、ローカル環境上でのJWT生成・署名とJWTの検証ができるようになりました。エンドポイント毎の認可を設定する方法に関してはメソッドのセキュリティ :: Spring Security - リファレンス などを参考に実装してみてください。
参考
実装をする際には以下のサイトを参考にさせていただきました。
JWTの作成・署名:https://ik.am/entries/818
JWTの検証:【Java】SpringBootでJWT認証する - Qiita
We Are Hiring!