Micronaut ことはじめ - 共通処理を組み込もう (4)

f:id:nemuzuka:20200611200142p:plain

前回 はエラーハンドリングを組み込んでみました。今回は CSRF 対策を共通処理っぽく入れてみたいと思います。

コードはこちら

CSRF

これです

SpringBoot だと @EnableWebSecurity 付ければ有効になるアレです。

Micronaut の場合は自分でやる必要がありそうです。なるほど。

View をレンダリングする時にデータを埋め込む

html を返す Controller それぞれで csrf トークンを生成してサーバ上の Session とレスポンスに設定する処理を書いてもいいですけど、人間なのでうっかり忘れることもあると思います。なので、共通的な処理として定義した方が良さそうだと考えました。

参考にしたのはこちら*1

ViewModelProcessor

JavaDoc には

Implementers of ViewModelProcessor process the ModelAndView and modify it prior to rendering by either adding or removing entries.

と書いてあります。レンダリングの前に処理を差し込むことができそうです。

@Slf4j
@Singleton
public class CsrfViewModelProcessor implements ViewModelProcessor {

  public static final String CSRF_PARAMETER_KEY = "csrfToken";
  @Override
  public void process(
      @Nonnull HttpRequest<?> request, @Nonnull ModelAndView<Map<String, Object>> modelAndView) {

    if (Objects.equals(request.getMethodName(), "GET")) {  // 1
      setCsrfForGet(request, modelAndView);
    } else {
      setCsrfForPost(request, modelAndView);
    }
  }

  private void setCsrfForGet( // 2
      HttpRequest<?> request, ModelAndView<Map<String, Object>> modelAndView) {
    var modelOpt = modelAndView.getModel();
    if (modelOpt.isEmpty()) {
      return;
    }

    var sessionOpt = SessionForRequest.find(request);
    if (sessionOpt.isEmpty()) {
      return;
    }

    sessionOpt.ifPresent(
        session -> {
          var csrf = UUID.randomUUID().toString();
          session.put(CSRF_PARAMETER_KEY, csrf);
          var responseMap = modelOpt.orElseThrow(() -> new AssertionError("invalid"));
          responseMap.put(CSRF_PARAMETER_KEY, csrf);
        });
  }

  private void setCsrfForPost( // 3
      HttpRequest<?> request, ModelAndView<Map<String, Object>> modelAndView) {

    var requestParameterOpt = request.getBody(Map.class);
    if (requestParameterOpt.isEmpty()) {
      return;
    }

    var modelOpt = modelAndView.getModel();
    if (modelOpt.isEmpty()) {
      return;
    }

    @SuppressWarnings("unchecked")
    var requestParameter =
        (Map<String, Object>) requestParameterOpt.orElseThrow(() -> new AssertionError("invalid"));
    var responseMap = modelOpt.orElseThrow(() -> new AssertionError("invalid"));

    var csrf = (String) requestParameter.get(CSRF_PARAMETER_KEY);
    responseMap.put(CSRF_PARAMETER_KEY, csrf);
  }
}

ViewModelProcessor を implements して @Singleton つけると有効になります。

  1. request パラメータの method で処理を分けます
  2. GET の時は csrf 用の token を作成し、Session とレスポンスに設定します
    • GET だけで良いかはケースバイケースかも
  3. POST の時はリクエストパラメータの csrf 用の token をレスポンスに設定します
    • POST だけで良いかはケースバイケースです
    • formタグは、GET と POST メソッドしかサポートしておらず、Micronaut は SpringBoot で言うところの HiddenHttpMethodFilter の仕組みがないので、このコードでは GET と POST しか送らない強い気持ちでやってます。*2

html 側に <input type="hidden" name="csrfToken" th:value="${csrfToken}"/> 的なのを設定するとレンダリングした html に埋め込まれているのが確認できます。

f:id:nemuzuka:20200614153843p:plain

POST の時に csrf チェック

チェックは filter を使ってみましょう。

OncePerRequestHttpServerFilter

コードは以下のような形になります。

@Slf4j
@RequiredArgsConstructor
@Filter(patterns = "/**", methods = HttpMethod.POST) // 1
public class CsrfFilter extends OncePerRequestHttpServerFilter {

  private final ViewsRenderer viewsRenderer;

  @Override
  public int getOrder() {
    return LOWEST_PRECEDENCE;
  }

  @Override
  protected Publisher<MutableHttpResponse<?>> doFilterOnce(
      HttpRequest<?> request, ServerFilterChain chain) {
    if (validateCsrfRequest(request)) { // 2
      return chain.proceed(request);
    }
    return Publishers.just(
        HttpResponse.status(HttpStatus.FORBIDDEN)
            .body(viewsRenderer.render("forbidden", Collections.EMPTY_MAP))
            .contentType(MediaType.TEXT_HTML));
  }
  private boolean validateCsrfRequest(HttpRequest<?> request) {

    if (Objects.equals(request.getUri(), UriBuilder.of("/login").build())) { // 3
      return true;
    }

    try {
      var session =
          SessionForRequest.find(request)
              .orElseThrow(() -> new IllegalStateException("Session is empty."));
      var sessionValue =
          session
              .get(CsrfViewModelProcessor.CSRF_PARAMETER_KEY, String.class)
              .orElseThrow(
                  () ->
                      new IllegalStateException(
                          "Session(" + CsrfViewModelProcessor.CSRF_PARAMETER_KEY + ") is empty."));

      @SuppressWarnings("unchecked")
      Map<String, Object> body = request.getBody(Map.class).orElse(Map.of());
      var requestValue = (String) body.get(CsrfViewModelProcessor.CSRF_PARAMETER_KEY);
      return Objects.equals(sessionValue, requestValue);  // 4
    } catch (IllegalStateException e) {
      log.info("Invalid request: {}", e.getMessage(), e);
      return false;
    }
  }
}

OncePerRequestHttpServerFilter を extends して @Filter つけると有効になります。

  1. patterns や methods で適応条件を指定できます
  2. csrf リクエストが正しいかチェックし、正しければ後続処理を行います
    • 不正だった場合、src/main/resources/views/forbidden.htmlレンダリングするようにしています
  3. 本来はログインの時も CSRF トークンチェックを入れた方が良いと思うのですが、むやみに Session 作られたくないのでひとまず除外しました
  4. session 上に格納している CRSF トークンとリクエストパラメータで設定している CSRF トークン値を比較します

Micronaut を起動してログイン成功した後に http://localhost:8080/tasks/add にアクセスして、csrfToken の値を書き換えて

f:id:nemuzuka:20200614155504p:plain

登録するボタンをクリックすると

f:id:nemuzuka:20200614155534p:plain

filter のチェックでエラー画面がレスポンスされました。

まとめ

今回は共通処理について書きました。SpringBoot のように至れり尽くせり感は無いですが、欲しけりゃ自分で好きなように作りな、というこスタンスは嫌いではありません。Web アプリ面倒ですね...。

*1:コード見た方が早いです

*2:SpringBoot もデフォルト off ですからね...