概要
Spring Securityを使ったWebアプリケーションで、AjaxからのアクセスがCSRF不正やセッション切れなどでエラーになったとき、ログインページにリダイレクトする代わりにHTTP STATUS 401 Unauthorizedを返却する。
課題
Spring boot + Spring SecurityでWebアプリケーションを作っていて、認証エラー時の処理を作るとき、以下の課題がある。
通常の認証エラー
まず、ここで認証エラーと呼んでいるのは、以下のようなケースである。
-
CSRF不正
CSRFチェック用のトークンが誤っているなどの理由でアクセスを拒否する。 -
セッション切れ
セッションがタイムアウトしてしまっているので、アクセスを拒否する。
これらのケースでは、ログイン画面にリダイレクトし、画面に「ログインし直してください」などのメッセージを表示すれば良いと思う。
Ajaxアクセス時の認証エラー
認証エラーになったAjaxアクセスに対してログイン画面へのリダイレクトを行おうとすると、以下の流れになる。
- Ajaxアクセスが発生する。
- サーバーサイドで認証エラーと判定し、ログインページへのリダイレクトを要求するレスポンスを返す。
- ブラウザはリダイレクトの要求を受けて、リダイレクト先のページを要求する。この時点でクライアントサイドにあるAjaxには何も通知されず、Ajaxから見ると応答を待っている状態である。
- リダイレクト先のページが要求されたので、サーバーサイドでは要求されたページを返す。このケースではログイン画面を返す。
- Ajaxに200 OKのレスポンスがあり、ログインページのHTMLが渡される。
つまり、正面からこの流れに沿って対応しようとすると、AjaxアクセスをしたJQueryのソースコード中で、ログイン画面が返却されていることを検知し、そこから再度ログインページへの遷移を試みるという流れになりそうである。
しかし、普通200 OKが返ってきたら、まさか中身がログインページだとは思わないし、なかなか茨の道に見えるので、ここでは401 Unauthorizedを返却し、Ajaxに失敗したことを伝えることにする。
こうすれば、Ajaxはfail()などの処理中で、エラーとして対処できるので、Ajaxの通常の流れに乗せることができる。
実装
Spring SecurityのSecutiry Configで認証エラー時の処理を設定できるので、ここを使う。
ソースコードを以下に示す。
package com.example.securingweb;
import java.io.IOException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.provisioning.InMemoryUserDetailsManager;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.access.AccessDeniedHandler;
@Configuration
@EnableWebSecurity
public class WebSecurityConfig {
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests((requests) -> requests
.requestMatchers("/", "/home", "/js/**").permitAll()
.anyRequest().authenticated())
.formLogin((form) -> form
.loginPage("/login")
.permitAll())
.logout((logout) -> logout.permitAll())
// 例外処理のHandler
.exceptionHandling(
exceptionHandling -> exceptionHandling.accessDeniedHandler(
// アクセス拒否時の処理をカスタマイズする。
new AccessDeniedHandler() {
// AccessDeniedHandlerクラスのhandleメソッドをオーバーライドする。
@Override
public void handle(HttpServletRequest request,
HttpServletResponse response,
AccessDeniedException accessDeniedException) throws IOException {
// Ajaxリクエストの場合、ログインページへのリダイレクトは行わずに401 Unauthorizedを返す。
if ("XMLHttpRequest".equals(request.getHeader("X-Requested-With"))) {
response.sendError(HttpServletResponse.SC_UNAUTHORIZED,
"Ajax Unauthorized");
return;
}
// Ajax以外のアクセスで起きた認証エラーはログイン画面へリダイレクトする。
response.sendRedirect(request.getContextPath() + "/login");
return;
}
}));
return http.build();
}
// ~その他の処理は省略~
}
上のように、handleメソッドをオーバーライドしたAccessDeniedHandlerクラスを渡すことで、認証エラー時の制御を設定できる。
テスト
以下のHTMLに書かれたAjaxでアクセスし、認証エラーを発生させてみる。認証エラーの原因はCSRFトークンを渡さないことによる。
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:th="https://www.thymeleaf.org"
xmlns:sec="https://www.thymeleaf.org/thymeleaf-extras-springsecurity6">
<head>
<title>Hello World!</title>
</head>
<body>
<h1 th:inline="text">Hello <span th:remove="tag" sec:authentication="name">thymeleaf</span>!</h1>
<form th:action="@{/logout}" method="post">
<input type="submit" value="Sign Out"/>
</form>
<div><input type="button" value="Ajaxによる認証エラーを発生させる"/ onclick="ajaxAccess()"></div>
<script th:src="@{/js/jquery-3.7.1.min.js}"></script>
<script>
const data =
[
{"id" : "1", "name" : "suzuki"},
{"id" : "2", "name" : "saito"}
];
function ajaxAccess() {
$.ajax({
url: '/hello',
type: 'post',
cache: false,
dataType: 'json',
data: data
})
.done(function (response) {
console.log('Ajaxでのアクセスに成功しました。')
})
.fail(function (xhr) {
console.log('Ajaxでのアクセスに失敗しました。')
})
.always(function (xhr, msg) {
console.log('------ END ------')
});
}
</script>
</body>
</html>
「Ajaxによる認証エラーを発生させる」ボタンを押すと以下になる。
POST http://localhost:8080/hello 401 (Unauthorized)
Ajaxでのアクセスに失敗しました。
------ END ------
InvalidSessionStrategyインターフェースのonInvalidSessionDetectedメソッドを実装するやり方について
「セッションが不正だったケースで401を返す」としたい場合、InvalidSessionStrategyインターフェースのonInvalidSessionDetectedメソッドを実装する方法もある。ただ、自分の場合、CSRFトークン不正なども含めた認証エラー全般でAjaxアクセスに対して401を返したかったので、上記の方法を採用した。
InvalidSessionStrategyインターフェースを使うやり方は、Invalid Session Strategyの名前の通りセッション不正時の挙動を決める方法なので、CSRFトークン不正については何も影響しない。
環境
- Spring boot 3.3.0
- Spring Security 6.3.0
- Java 17
参考
- Spring Security の認証処理アーキテクチャを学ぶ
https://qiita.com/d-yosh/items/7e905bfc20a1bfe5243a - Ajax call after session expired not redirecting to login page - spring boot
https://stackoverflow.com/questions/60194464/ajax-call-after-session-expired-not-redirecting-to-login-page-spring-boot - Spring Boot + Spring Security使用時のSessionTimeout対応
https://www.greptips.com/posts/847/#google_vignette
→ Ajaxの場合の対処法についても記載がある。 - AuthenticationEntryPointのcommenceメソッドの実装例(Spring Security 6.0)
https://qiita.com/3087KT/items/fa516e82766a08f4be3a - Spring Security part VI : Session Timeout handling for Ajax calls
https://www.doanduyhai.com/blog/?p=950 - Spring Security / サーブレットアプリケーション / 認証 / セッション管理 / 無効なセッション戦略のカスタマイズ
https://spring.pleiades.io/spring-security/reference/servlet/authentication/session-management.html#_customizing_the_invalid_session_strategy
→ ここにInvalidSessionStrategyの使い方が載っている。