Spring Bootによる開発を初めて日が浅いため色々と分からないことがあり、そのうちの一つとしてWebアプリケーションにおけるAjaxアクセス時のエラー返却方法が分からず苦戦していたので、調査してみた。

困っていたこと

  1. Ajaxアクセス時の例外制御をWeb側と独立して処理できない
  2. CSRFトークンを使っているとセッション切れ時に403エラーとなる

まず1についてだが、@ControllerAdviceによる例外制御を入れるとWebページへの通常アクセスも捕まえてしまうので、Ajaxアクセス専用の処理を入れづらい。

2については、CSRFトークン利用時(というかSpring Securityデフォルト設定時)にセッション切れ後のPOSTアクセスにて、401でなく403になってしまうという問題がある。この問題は通常のWebアクセスにおけるPOSTメソッド利用時でも発生する。

解決策

@ControllerAdviceにおけるannotationsパラメータの利用

ググっても回答は見つからなかったがControllerAdviceのソースを眺めていたら以下の記述を発見。

/**
 * Array of annotations.
 * <p>Controllers that are annotated with this/one of those annotation(s)
 * will be assisted by the {@code @ControllerAdvice} annotated class.
 * <p>Consider creating a special annotation or use a predefined one,
 * like {@link RestController @RestController}.
 * @since 4.0
 */
Class<? extends Annotation>[] annotations() default {};

駄目元で@ControllerAdvice(annotations = {RestController.class})のように書いてみたら、@RestControllerを付加したコントローラの例外のみ拾ってくれる(らしい)事が分かった。

AuthenticationEntryPointとAccessDeniedHandlerのカスタマイズ

以下の情報を元に上記クラスのカスタマイズを行う事で、セッション切れ時の動作+Ajax動作時の動作を変更できた。

CSRFトークン例外制御の制約

CSRFトークンアクセスの例外制御は初回アクセスしか401にならず、2回目以降は403になってしまうみたいなので、Ajaxアクセスで401を検出したら速やかにログイン画面に飛ばす必要がある。

初回アクセス

Ajaxアクセス時に実装した例外処理からの返却値が返る。

json={"readyState":4,"responseText":"{\"timestamp\":1512293582196,\"status\":401,\"error\":\"Unauthorized\",\"message\":\"セッションは無効です\",\"path\":\"/ok\"}","responseJSON":{"timestamp":1512293582196,"status":401,"error":"Unauthorized","message":"セッションは無効です","path":"/ok"},"status":401,"statusText":"error"}
main.js:24 message=セッションは無効です
main.js:25 status=401
main.js:26 textStatus=error
main.js:27 errorThrown=
main.js:30 通信完了

2回目アクセス

実装した例外処理に入ってないのでその手前で捌かれている模様。

json={"readyState":4,"responseText":"{\"timestamp\":1512293587636,\"status\":403,\"error\":\"Forbidden\",\"message\":\"Invalid CSRF Token 'a344e496-0a4d-4a1c-bb53-344df2e41456' was found on the request parameter '_csrf' or header 'X-CSRF-TOKEN'.\",\"path\":\"/ok\"}","responseJSON":{"timestamp":1512293587636,"status":403,"error":"Forbidden","message":"Invalid CSRF Token 'a344e496-0a4d-4a1c-bb53-344df2e41456' was found on the request parameter '_csrf' or header 'X-CSRF-TOKEN'.","path":"/ok"},"status":403,"statusText":"error"}
main.js:24 message=Invalid CSRF Token 'a344e496-0a4d-4a1c-bb53-344df2e41456' was found on the request parameter '_csrf' or header 'X-CSRF-TOKEN'.
main.js:25 status=403
main.js:26 textStatus=error
main.js:27 errorThrown=
main.js:30 通信完了

実装方法

今回の実装は以下を参照。

springboot-study/rest-json-status-test at master · orimajp/springboot-study

REST例外処理共通処理

@ControllerAdviceで取得可能な例外の内、対応処理が書かれていないものについてはExceptionに対応した例外ハンドラが呼ばれる模様。以下に含まれない例外については追加が必要。

package com.example.restjsonstatustest;

import org.springframework.http.HttpStatus;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.web.bind.MissingServletRequestParameterException;
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.ResponseBody;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException;

import java.util.HashMap;
import java.util.Map;
import java.util.logging.Logger;

@ControllerAdvice(annotations = {RestController.class})
public class RestExceptionHandler {

	private final Logger logger = Logger.getLogger(getClass().getName());

	@ResponseStatus(HttpStatus.BAD_REQUEST)
	@ExceptionHandler({MethodArgumentTypeMismatchException.class})
	@ResponseBody
	public Map<String, Object> methodArgumentTypeMismatchExceptionErrorHandler(MethodArgumentTypeMismatchException e) {
		logger.warning("エラー:" + e.getMessage());
		final Map<String, Object> errorMap = new HashMap<>();
		errorMap.put("message", "パラメータのデータ型が正しくありません");
		errorMap.put("status", HttpStatus.BAD_REQUEST);
		return errorMap;
	}

	@ResponseStatus(HttpStatus.BAD_REQUEST)
	@ExceptionHandler({MissingServletRequestParameterException.class})
	@ResponseBody
	public Map<String, Object> issingServletRequestParameterExceptionErrorHandler(MissingServletRequestParameterException e) {
		logger.warning("エラー:" + e.getMessage());
		final Map<String, Object> errorMap = new HashMap<>();
		errorMap.put("message", "必須パラメータが指定されていません");
		errorMap.put("status", HttpStatus.BAD_REQUEST);
		return errorMap;
	}

	@ResponseStatus(HttpStatus.FORBIDDEN)
	@ExceptionHandler({AccessDeniedException.class})
	@ResponseBody
	public Map<String, Object> accessDeniedExceptionErrorHandler(AccessDeniedException e) {
		logger.warning("エラー:" + e.getMessage());
		final Map<String, Object> errorMap = new HashMap<>();
		errorMap.put("message", "認可されないアクセスです");
		errorMap.put("status", HttpStatus.FORBIDDEN);
		return errorMap;
	}

	@ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
	@ExceptionHandler({Exception.class})
	@ResponseBody
	public Map<String, Object> exceptionErrorHandler(Exception e) {
		logger.warning("エラー:" + e.getMessage());
		final Map<String, Object> errorMap = new HashMap<>();
		errorMap.put("message", "サーバエラーです");
		errorMap.put("status", HttpStatus.INTERNAL_SERVER_ERROR);
		return errorMap;
	}

}

上記では各例外毎に別メソッドで対応しているが、以下のように一つのメソッド内で例外クラスを判定して処理を分岐する方法も考えられる。ただ、数が増えてくると対応しづらくなる気はする。

	@ExceptionHandler({Exception.class})
	@ResponseBody
	public ResponseEntity exceptionErrorHandler(Exception e) {
		if (e.getCause() instanceof AccessDeniedException) {
			logger.warning("エラー:" + e.getMessage());
			final Map<String, Object> errorMap = new HashMap<>();
			errorMap.put("message", "認可エラーです");
			errorMap.put("status", HttpStatus.FORBIDDEN);
			return ResponseEntity.status(HttpStatus.FORBIDDEN).body(errorMap);
		}
		logger.warning("エラー:" + e.getMessage());
		final Map<String, Object> errorMap = new HashMap<>();
		errorMap.put("message", "サーバエラーです");
		errorMap.put("status", HttpStatus.INTERNAL_SERVER_ERROR);
		return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(errorMap);
	}

AuthenticationEntryPointカスタマイズ

こちらはAjaxアクセスにて認証エラーが発生した場合への対応を行っている。

package com.example.restjsonstatustest;

import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.authentication.LoginUrlAuthenticationEntryPoint;

import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;

/*
 * http://progmemo.wp.xdomain.jp/archives/847
 */
public class SessionExpiredDetectingLoginUrlAuthenticationEntryPoint extends LoginUrlAuthenticationEntryPoint {

	public SessionExpiredDetectingLoginUrlAuthenticationEntryPoint(String loginFormUrl) {
		super(loginFormUrl);
	}

	/**
	 * Ajaxアクセス時対応
	 *
	 * @param request HttpServletRequest
	 * @param response HttpServletResponse
	 * @param authException AuthenticationException
	 * @throws IOException
	 * @throws ServletException
	 */
	@Override
	public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException {
		if ("XMLHttpRequest".equals(request.getHeader("X-Requested-With"))) {
			response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "セッションは無効です");
			return;
		}
		super.commence(request, response, authException);
	}

}

SecurityConfig

後半にあるAccessDeniedHandlerのBean定義にてCSRFトークン消失時に発生するMissingCsrfTokenExceptionへの対応を行っている。

package com.example.restjsonstatustest;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.web.AuthenticationEntryPoint;
import org.springframework.security.web.access.AccessDeniedHandler;
import org.springframework.security.web.access.AccessDeniedHandlerImpl;
import org.springframework.security.web.csrf.MissingCsrfTokenException;

import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.util.logging.Logger;

/*
 * https://www.slideshare.net/navekazu/spring-bootweb-55470364
 * http://progmemo.wp.xdomain.jp/archives/858
 */
@Configuration
@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true, securedEnabled = true)
public class SecurityConfig extends WebSecurityConfigurerAdapter {

	private final Logger logger = Logger.getLogger(getClass().getName());

	@Override
	protected void configure(HttpSecurity http) throws Exception {
		http.authorizeRequests()
				.anyRequest().authenticated();

		http.formLogin()
				.loginPage("/login")
				.usernameParameter("username")
				.passwordParameter("password")
				.permitAll();

		http.exceptionHandling()
				.authenticationEntryPoint(authenticationEntryPoint())
				.accessDeniedHandler(accessDeniedHandler());
	}

	@Override
	protected void configure(AuthenticationManagerBuilder auth) throws Exception {
		auth.inMemoryAuthentication()
				.withUser("user").password("pass").roles("USER").and()
				.withUser("admin").password("pass").roles("USER", "ADMIN");
	}

	@Bean
	AuthenticationEntryPoint authenticationEntryPoint() {
		return new SessionExpiredDetectingLoginUrlAuthenticationEntryPoint("/login");
	}

	/**
	 * タイムアウト如ルCSRFトークン消失対応
	 *
	 * @return AccessDeniedHandler
	 */
	@Bean
	AccessDeniedHandler accessDeniedHandler() {
		return new AccessDeniedHandler() {
			@Override
			public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException)
					throws IOException, ServletException {
				if (accessDeniedException instanceof MissingCsrfTokenException) {
					logger.warning("CSRFトークンが無効");
					authenticationEntryPoint().commence(request, response, null);
				} else {
					new AccessDeniedHandlerImpl().handle(request, response, accessDeniedException);
				}
			}
		};
	}

}

認証コントローラ

package com.example.restjsonstatustest;

import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;

@Controller
public class TopController {

	@RequestMapping(value = "/", method = RequestMethod.GET)
	public String index() {
		return "index";
	}

	@RequestMapping(value = "/login", method = RequestMethod.GET)
	public String login() {
		return "login";
	}

}

Webテストコントローラ

package com.example.restjsonstatustest;

import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.bind.annotation.RequestParam;

@Controller
public class TestController {

	// 正常系(POST/GET共用)
	@RequestMapping(value = "/okrequest")
	public String okRequest() {
		return "result";
	}

	// パラメータエラー
	@RequestMapping(value = "/paramcheck", method = RequestMethod.GET)
	public String missMatchParameter(@RequestParam Integer value) {
		return "result";
	}

	// Internal Server Error
	@RequestMapping(value = "/internalservererror", method = RequestMethod.GET)
	public String internalServerError(Model model) {
		if (model != null) {
			throw new IllegalArgumentException("テストエラー");
		}
		return "result";
	}

	// 権限チェック用
	@PreAuthorize("hasRole('ADMIN')")
	@RequestMapping(value = "/adminonly", method = RequestMethod.GET)
	public String adminOnly() {
		return "result";
	}

}

RESTテストコントローラ

package com.example.restjsonstatustest;

import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

@RestController
public class RestTestController {

	// OKパターン
	@RequestMapping(value = "/ok", method = RequestMethod.POST)
	public ResponseEntity ok() {
		return ResponseEntity.ok("ok");
	}

	// GET バリデートエラー用
	@RequestMapping(value = "/valid", method = RequestMethod.GET)
	public ResponseEntity valid(@RequestParam Integer value) {
		return ResponseEntity.ok("ok");
	}

	// Internal Server Error
	@RequestMapping(value = "/servererrorrest", method = RequestMethod.GET)
	public ResponseEntity internalServerErrorRest(Model model) {
		if (model != null) {
			throw new IllegalArgumentException("テストエラー");
		}
		return ResponseEntity.ok("ok");
	}

	// 権限テスト
	@PreAuthorize("hasRole('ADMIN')")
	@RequestMapping(value = "/adminrest", method = RequestMethod.GET)
	public ResponseEntity adminOnlyRest() {
		return ResponseEntity.ok("ok");
	}

}

ログイン画面

<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
	<meta charset="UTF-8">
	<title>ログインページ</title>
</head>
<body>

<form th:action="@{/login}" method="post">
	<div>
		<label>ユーザ名:
			<input type="text" name="username">
		</label>
	</div>
	<div>
		<label>パスワード:
			<input type="password" name="password">
		</label>
	</div>
	<div><input type="submit" value="ログイン"></div>
</form>

<script src="https://code.jquery.com/jquery-3.2.1.min.js"></script>
<script>
	$(function () {
		$('input[name="username"]').focus();
	});
</script>
</body>
</html>

テスト画面

<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
	<meta charset="UTF-8">
	<meta name="_csrf" th:content="${_csrf.token}" />
	<meta name="_csrf_header" th:content="${_csrf.headerName}" />
	<title>テストページ</title>
</head>
<body>
<h1 th:inline="text">こんにちは [[${#httpServletRequest.remoteUser}]]</h1>

<h2>REST(Ajax)テスト</h2>
<div>
	<input type="button" id="okButton" value="OKパターン">
</div>

<div>
	<input type="button" id="getRequiredErrorButton" value="GET必須チェックエラー">
</div>

<div>
	<input type="button" id="getValidateErrorButton" value="GETデータ型バリデートエラー">
</div>

<div>
	<input type="button" id="serverErrorRestButton" value="サーバエラー">
</div>

<div>
	<input type="button" id="adminrest" value="ADMINユーザのみ">
</div>

<div>
	<input type="button" id="notfound" value="リンク切れ">
</div>

<h2>Webテスト</h2>
<div>
	<a th:href="@{/okrequest}">OKパターン(GET)</a>
</div>
<div>
	<form th:action="@{/okrequest}" method="post">
		<input type="submit" value="OKパターン(POST)">
	</form>
</div>

<div>
	<a th:href="@{/paramcheck}">GET引数無しエラー</a>
</div>
<div>
	<a th:href="@{paramcheck(value=${'aaa'})}">GETデータ型バリデートエラー</a>
</div>
<div>
	<a th:href="@{/internalservererror}">サーバエラー</a>
</div>
<div>
	<a th:href="@{/adminonly}">ADMINユーザのみ</a>
</div>
<div>
	<a th:href="@{/notfound}">リンク切れ</a>
</div>

<hr>

<form th:action="@{/logout}" method="post">
	<input type="submit" value="ログアウト">
</form>

<script src="https://code.jquery.com/jquery-3.2.1.min.js"></script>
<script th:src="@{/js/main.js}"></script>
</body>
</html>

外部JavaScript

Ajaxアクセスに利用。

$(function () {
	$('#okButton').on('click', function () {
		var csrfToken = $('meta[name="_csrf"]').attr('content');
		var csrfHeader = $('meta[name="_csrf_header"]').attr('content');
		$.ajax({
			url: '/ok',
			type: 'post',
			cache: false,
			beforeSend: function (xhr) {
				xhr.setRequestHeader(csrfHeader, csrfToken);
			}
		}).done(function (data,textStatus,jqXHR) {
			console.log('textStatus=' + textStatus);
			console.log('jsXHR.status=' + jqXHR.status);
			var json = JSON.stringify(data);
			console.log('json=' + json);
			alert('Ok json=' + json);
		}).fail(function (jqXHR, textStatus, errorThrown) {
			var json = JSON.stringify(jqXHR);
			console.log('json=' + json);
			console.log('message=' + jqXHR.responseJSON.message);
			console.log('status=' + jqXHR.status);
			console.log('textStatus=' + textStatus);
			console.log('errorThrown=' + errorThrown);
			alert("Error status=" + jqXHR.status);
		}).always(function () {
			console.log('通信完了');
		});
	});

	$('#getRequiredErrorButton').on('click', function () {
		$.ajax({
			url: '/valid',
			type: 'get',
			cache: false
		}).done(function (data,textStatus,jqXHR) {
			console.log('textStatus=' + textStatus);
			console.log('jsXHR.status=' + jqXHR.status);
			var json = JSON.stringify(data);
			console.log('json=' + json);
			alert('Ok json=' + json);
		}).fail(function (jqXHR, textStatus, errorThrown) {
			var json = JSON.stringify(jqXHR);
			console.log('json=' + json);
			console.log('message=' + jqXHR.responseJSON.message);
			console.log('status=' + jqXHR.status);
			console.log('textStatus=' + textStatus);
			console.log('errorThrown=' + errorThrown);
			alert("Error status=" + jqXHR.status);
		});
	});

	$('#getValidateErrorButton').on('click', function () {
		$.ajax({
			url: '/valid?value=aaaa',
			type: 'get',
			cache: false
		}).done(function (data,textStatus,jqXHR) {
			console.log('textStatus=' + textStatus);
			console.log('jsXHR.status=' + jqXHR.status);
			var json = JSON.stringify(data);
			console.log('json=' + json);
			alert('Ok json=' + json);
		}).fail(function (jqXHR, textStatus, errorThrown) {
			var json = JSON.stringify(jqXHR);
			console.log('json=' + json);
			console.log('message=' + jqXHR.responseJSON.message);
			console.log('status=' + jqXHR.status);
			console.log('textStatus=' + textStatus);
			console.log('errorThrown=' + errorThrown);
			alert("Error status=" + jqXHR.status);
		});
	});

	$('#serverErrorRestButton').on('click', function () {
		$.ajax({
			url: '/servererrorrest',
			type: 'get',
			cache: false
		}).done(function (data,textStatus,jqXHR) {
			console.log('textStatus=' + textStatus);
			console.log('jsXHR.status=' + jqXHR.status);
			var json = JSON.stringify(data);
			console.log('json=' + json);
			alert('Ok json=' + json);
		}).fail(function (jqXHR, textStatus, errorThrown) {
			var json = JSON.stringify(jqXHR);
			console.log('json=' + json);
			console.log('message=' + jqXHR.responseJSON.message);
			console.log('status=' + jqXHR.status);
			console.log('textStatus=' + textStatus);
			console.log('errorThrown=' + errorThrown);
			alert("Error status=" + jqXHR.status);
		});
	});

	$('#adminrest').on('click', function () {
		$.ajax({
			url: '/adminrest',
			type: 'get',
			cache: false
		}).done(function (data,textStatus,jqXHR) {
			console.log('textStatus=' + textStatus);
			console.log('jsXHR.status=' + jqXHR.status);
			var json = JSON.stringify(data);
			console.log('json=' + json);
			alert('Ok json=' + json);
		}).fail(function (jqXHR, textStatus, errorThrown) {
			var json = JSON.stringify(jqXHR);
			console.log('json=' + json);
			console.log('message=' + jqXHR.responseJSON.message);
			console.log('status=' + jqXHR.status);
			console.log('textStatus=' + textStatus);
			console.log('errorThrown=' + errorThrown);
			alert("Error status=" + jqXHR.status);
		});
	});

	$('#notfound').on('click', function () {
		$.ajax({
			url: '/notfound',
			type: 'get',
			cache: false
		}).done(function (data,textStatus,jqXHR) {
			console.log('textStatus=' + textStatus);
			console.log('jsXHR.status=' + jqXHR.status);
			var json = JSON.stringify(data);
			console.log('json=' + json);
			alert('Ok json=' + json);
		}).fail(function (jqXHR, textStatus, errorThrown) {
			var json = JSON.stringify(jqXHR);
			console.log('json=' + json);
			console.log('message=' + jqXHR.responseJSON.message);
			console.log('status=' + jqXHR.status);
			console.log('textStatus=' + textStatus);
			console.log('errorThrown=' + errorThrown);
			alert("Error status=" + jqXHR.status);
		});
	});

});