Spring Boot 2.3.4.RELEASE時点の環境の話です。
Spring WebFluxではHTTPリクエストを処理するための方法を幾つか提供してくれている。
この時にHTTPレスポンスデータを生成するために、
多くの場合はRouterFunction
や@Controller
を使用することが多いと思う。
RouterFunction
にはServerRequest
が渡されるので、ServerRequest#bodyToMono(Class<? extends T>)
を呼び出しすれば
リクエストボディのJSONデータをパース出来る。
Spring Bootでは@Controller
の場合は@RequestBody
アノテーションが付与されていれば
フレームワーク側でリクエストボディをパースしてくれる。
ほとんどの場合はこの方法でリクエストボディのJSONをアプリケーション内で利用するオブジェクトに変換できる。
しかし、Spring Securityのカスタム認証はWebFilterの段階で処理を行うため、
RouterFunction
などが呼び出される。
このときWebFilter
にはServerWebExchange
が渡されるが、ServerWebExchange#getRequest()
メソッドは
ServerHttpRequest
を返す。
ServerHttpRequest
はServerRequest
との間に継承関係なども持たないため、
ServerRequest#bodyToMono(Class<? extends T>)
のような便利な関数を持たない。
ServerWebExchange#getFormData()
でフォームデータの取得などはできるが、リクエストボディのJSONのパースなどは自分で行わなければいけない。
このパース処理をSpring Bootと同じようにするためには、ServerCodecConfigurer
をAutowired
で取得し、
ServerCodecConfigurer#getReaders()
でListSE<HttpMessageReader<?>>
を取得、
HTTPリクエストボディをパースするためのHttpMessageReader
はStream#filter()
で取得して...といった面倒な作業が必要になる。
これをSpring Securityのorg.springframework.security.web.server.authentication.ServerAuthenticationConverter
で書くと大体以下のようになる。
import java.io.Serializable;
import java.util.Collection;
import java.util.Collections;
import java.util.List;
import java.util.Objects;
import javax.naming.AuthenticationException;
import com.fasterxml.jackson.annotation.JsonCreator;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.annotation.JsonProperty;
import org.springframework.core.ResolvableType;
import org.springframework.http.MediaType;
import org.springframework.http.codec.HttpMessageReader;
import org.springframework.http.codec.ServerCodecConfigurer;
import org.springframework.http.server.reactive.ServerHttpRequest;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.web.server.authentication.ServerAuthenticationConverter;
import org.springframework.web.server.ServerWebExchange;
import reactor.core.publisher.Mono;
public class JsonLoginServerAuthenticationConverter implements ServerAuthenticationConverter {
private final List<HttpMessageReader<?>> httpMessageReaders;
public JsonLoginServerAuthenticationConverter(ServerCodecConfigurer serverCodecConfigurer) {
this(serverCodecConfigurer.getReaders());
}
public JsonLoginServerAuthenticationConverter(List<HttpMessageReader<?>> httpMessageReaders) {
this.httpMessageReaders = Objects.requireNonNull(httpMessageReaders);
}
@Override
public Mono<Authentication> convert(ServerWebExchange exchange) {
ServerHttpRequest request = exchange.getRequest();
MediaType contentType = request.getHeaders().getContentType();
MediaType acceptType = MediaType.APPLICATION_JSON;
// Content-Type のチェック。
if (contentType == null || acceptType.isCompatibleWith(contentType)) {
return Mono.error(new AuthenticationException("Invalid Content-Type"));
}
ResolvableType resolvableType = ResolvableType.forClass(JsonParameter.class);
// JsonParameter は username, password をフィールドに格納するだけのクラス。
// JsonAuthentication は JsonParameter を保存するだけの Authentication のサブクラス。
// どちらも定義は省略。
return this.httpMessageReaders.stream()
// Content-Type: application/json のリクエストデータを JsonParameter.class に変換可能な
// HttpMessageReader 実装を探す。
.filter(httpMessageReader ->
httpMessageReader.canRead(resolvableType, acceptType))
.findFirst()
.orElseThrow(() -> new IllegalStateException("Could not read JSON data."))
.readMono(resolvableType, request, Collections.emptyMap())
.cast(JsonParameter.class)
.map(JsonAuthentication::new);
}
@JsonIgnoreProperties(ignoreUnknown = true)
static class JsonParameter {
private final String username;
private final String password;
@JsonCreator
public JsonParameter(
@JsonProperty("username") String username,
@JsonProperty("password") String password) {
this.username = username;
this.password = password;
}
public String getUsername() {
return this.username;
}
public String getPassword() {
return this.password;
}
@Override
public boolean equals(Object o) {
if (this == o) {
return true;
}
if (o == null || getClass() != o.getClass()) {
return false;
}
JsonParameter that = (JsonParameter) o;
return Objects.equals(username, that.username) &&
Objects.equals(password, that.password);
}
@Override
public int hashCode() {
return Objects.hash(username, password);
}
@Override
public String toString() {
return "JsonParameter(" +
"username=" + this.username +
", password=" + this.password +
")";
}
}
}
正直、この実装はかなり面倒だと思ってしまう。
非同期I/Oで何時送られれ来るか分からないリクエストボディを処理する事と、
Spring Bootでメッセージボディを処理するオブジェクトに何が使用されるか実行時まで確定しないという都合から
こうなるのは理解できる。
しかし、Servert APIとは異なる独自のHTTPメッセージ処理であることを差し引いても
この手順を毎回書くのは非常に厄介に思えた。
Mono
が使用されている都合上、便利なユーティリティー関数を用意するのも少し面倒になる。
などと思っていたが、ある日ServerRequest#create(ServerWebExchange, List<HttpMessageReader<?>>)
関数が存在することに気が付いた。
この関数で生成されたServerRequest
はメッセージボディの処理に与えられたList<HttpMessageReader<?>>
の中から
適切なHttpMessageReader
を取り出して使用してくれる。
メッセージボディを何に変換したいのかはBodyExtractors
に用意されている関数からBodyExtractor
を生成するだけでよい。
先ほどのコードでもコード量を大きく減らすことが出来る。
Content-Type
のチェックは念のため残している。
import java.io.Serializable;
import java.util.Collection;
import java.util.List;
import java.util.Objects;
import com.fasterxml.jackson.annotation.JsonCreator;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.annotation.JsonProperty;
import org.springframework.http.codec.HttpMessageReader;
import org.springframework.http.codec.ServerCodecConfigurer;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.web.server.authentication.ServerAuthenticationConverter;
import org.springframework.web.reactive.function.BodyExtractors;
import org.springframework.web.reactive.function.server.ServerRequest;
import org.springframework.web.server.ServerWebExchange;
import reactor.core.publisher.Mono;
public class JsonLoginServerAuthenticationConverter implements ServerAuthenticationConverter {
private final List<HttpMessageReader<?>> httpMessageReaders;
public JsonLoginServerAuthenticationConverter(ServerCodecConfigurer serverCodecConfigurer) {
this(serverCodecConfigurer.getReaders());
}
public JsonLoginServerAuthenticationConverter(List<HttpMessageReader<?>> httpMessageReaders) {
this.httpMessageReaders = Objects.requireNonNull(httpMessageReaders);
}
@Override
public Mono<Authentication> convert(ServerWebExchange exchange) {
ServerHttpRequest request = exchange.getRequest();
MediaType contentType = request.getHeaders().getContentType();
if (contentType == null || MediaType.APPLICATION_JSON.isCompatibleWith(contentType)) {
return Mono.error(new AuthenticationException("Invalid Content-Type"));
}
return ServerRequest.create(exchange, this.httpMessageReaders)
.body(BodyExtractors.toMono(JsonParameter.class))
.map(JsonAuthentication::new);
}
}
ServerRequest#create(ServerWebExchange, List<HttpMessageReader<?>>)
はWebFilter
で有用な機能なのに、
あまり紹介されていない。