1. 인증 처리 : AuthenticationSuccessHandler & AuthenticationFailureHandler
From 인증과 같이 인증 성공과 실패 시 handler를 적용한다. Form 인증 방식과 다를 게 없고, View 형태로 응답하는 것이 아니라 response의 body에 정보를 담아서 보내주는 형태이다.
AuthenticationSuccessHandler
response에 정보를 담기 위해서 ObjectMapper를 불러온다. 그리고 앞선 글에서 살펴본 AuthenticationManager에서 성공한 인증 객체, authentication을 받아와서 Account 객체로 만들어서 응답값으로 반환한다.
public class AjaxAuthenticationSuccessHandler implements AuthenticationSuccessHandler {
private ObjectMapper objectMapper = new ObjectMapper();
@Override
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
Account account = (Account) authentication.getPrincipal();
response.setStatus(HttpStatus.OK.value());
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
objectMapper.writeValue(response.getWriter(), account);
}
}
AuthenticationFailureHandler
Form 인증 때와 같이 예외 상황에 따라 분기하여 예외 처리만 해주고, errorMessage를 response에 담아서 반환한다.
public class AjaxAuthenticationFailureHandler implements AuthenticationFailureHandler {
private ObjectMapper objectMapper = new ObjectMapper();
@Override
public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) throws IOException, ServletException {
String errorMessage = "Invalid username or password";
response.setStatus(HttpStatus.UNAUTHORIZED.value());
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
if (exception instanceof BadCredentialsException) {
errorMessage = "Invalid Username or Password";
} else if (exception instanceof DisabledException) {
errorMessage = "Locked";
} else if (exception instanceof CredentialsExpiredException) {
errorMessage = "Expired password";
}
objectMapper.writeValue(response.getWriter(), errorMessage);
}
}
AjaxLoginProcessingFilter
해당 handler들을 직접 http에 등록할 필요없이, 이전 시간에 만들어두었던 ProcessingFilter에서 set 메서드들을 이용하여 handler들을 추가해주면 정상 작동한다.
@Bean
public AjaxLoginProcessingFilter ajaxLoginProcessingFilter() throws Exception {
AjaxLoginProcessingFilter ajaxLoginProcessingFilter = new AjaxLoginProcessingFilter();
ajaxLoginProcessingFilter.setAuthenticationManager(authenticationManagerBean());
//인증 성공, 실패 시 handler 처리
ajaxLoginProcessingFilter.setAuthenticationSuccessHandler(ajaxAuthenticationSuccessHandler());
ajaxLoginProcessingFilter.setAuthenticationFailureHandler(ajaxAuthenticationFailureHandler());
return ajaxLoginProcessingFilter;
}
인증 성공 응답
성공 시 account 객체가 잘 전달된다.
인증 실패 응답
잘못된 로그인 정보를 요청으로 보내면, errorMessage가 body로 넘어온다.
2. 인가 처리
인가 처리는 FilterSecurityInterceptor가 처리한다. 앞서 배웠던 내용을 복습하자면 이 클래스는 두 가지 경우를 처리한다.
- 인증을 받지 않는 익명 사용자의 접근처리
- 인증을 받은 사용자의 권한 정보 처리
예외 사항에 따른 실제 처리는 ExceptionTranslationFilter가 처리한다. 구문을 한번 살펴보자.
private void handleSpringSecurityException(HttpServletRequest request, HttpServletResponse response, FilterChain chain, RuntimeException exception) throws IOException, ServletException {
if (exception instanceof AuthenticationException) {
this.logger.debug("Authentication exception occurred; redirecting to authentication entry point", exception);
this.sendStartAuthentication(request, response, chain, (AuthenticationException)exception);
} else if (exception instanceof AccessDeniedException) {
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
if (!this.authenticationTrustResolver.isAnonymous(authentication) && !this.authenticationTrustResolver.isRememberMe(authentication)) {
this.logger.debug("Access is denied (user is not anonymous); delegating to AccessDeniedHandler", exception);
this.accessDeniedHandler.handle(request, response, (AccessDeniedException)exception);
} else {
this.logger.debug("Access is denied (user is " + (this.authenticationTrustResolver.isAnonymous(authentication) ? "anonymous" : "not fully authenticated") + "); redirecting to authentication entry point", exception);
this.sendStartAuthentication(request, response, chain, new InsufficientAuthenticationException(this.messages.getMessage("ExceptionTranslationFilter.insufficientAuthentication", "Full authentication is required to access this resource")));
}
}
}
AuthenticationException인 경우 sendStartAuthentication을 실행하고, AccessDeniedException인 경우 accessDeniedHandler를 실행하는 큰 흐름을 볼 수 있다. 즉 인증에 실패하면 RequestCache에 사용자의 요청 정보를 저장해놓고 다시 인증 화면으로 보내고, 인가에 실패하면 accessDeniedHandler를 통해서 어떤 처리를 하도록 되어있다.
이런 구조를 이해하고 AuthenticationEntryPoint, AccessDeniedHandler를 작성해본다.
AjaxLoginAuthenticationEntryPoint
common 패키지에 AjaxLoginAuthenticationEntryPoint를 생성한다.
AuthenticationEntryPoint를 상속받고, commence 메서드를 Override 해준다.
public class AjaxLoginAuthenticationEntryPoint implements AuthenticationEntryPoint {
//익명 사용자가 인증이 필요한 자원에 접근한 경우, commence 메서드가 호출됨
@Override
public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException e) throws IOException, ServletException {
response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "UnAuthorized");
}
}
AjaxAccessDeniedHandler
AccessDeniedHandler를 상속받고, handler 메서드를 Override 해준다.
public class AjaxAccessDeniedHandler implements AccessDeniedHandler {
//인증을 받은 사용자가 권한이 없는 경우 handle 메서드 호출
@Override
public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException e) throws IOException, ServletException {
response.sendError(HttpServletResponse.SC_FORBIDDEN, "Access is denied");
}
}
AjaxSecurityConfig
설정 파일에 authenticationEntryPoint, accessDeniedHandler 메서드를 추가한다. 각 객체들을 그대로 사용할 것이기 때문에 따로 Bean으로 만들지 않고 new 키워드를 통해 바로 생성해주었다.
추가로 .antMatchers로 MANAGER 권한이 접근할 수 있는 messages url을 설정해주었는데, MessageController에서 처리한다.
@Override
protected void configure(HttpSecurity http) throws Exception {
http
.antMatcher("/api/**")
.authorizeRequests()
.antMatchers("/api/messages").hasAuthority("MANAGER")
.anyRequest().authenticated()
.and()
.addFilterBefore(ajaxLoginProcessingFilter(), UsernamePasswordAuthenticationFilter.class) //기존 필터 앞에 위치할때 사용하는 method
;
http
.exceptionHandling()
.authenticationEntryPoint(new AjaxLoginAuthenticationEntryPoint())
.accessDeniedHandler(new AjaxAccessDeniedHandler())
;
http.csrf().disable(); //기본적으로 csrf 토큰값을 들고 요청을 해야되는데, 임시적으로 off 한다.
}
MessageController
MANAGER 권한을 가진 사용자만 접근 가능한 controller를 작성한다. 해당 url로 접근 시 body 정보를 전달해주기 위해 @ResponseBody 어노테이션을 달아주었다.
@Controller
public class MessageController {
@GetMapping("/messages")
public String messages()throws Exception {
return "messages";
}
@GetMapping("/api/messages")
@ResponseBody
public String apiMessage() {
return "messages ok";
}
}
3. 인가 처리 실습
상황에 따른 인가 처리 실습을 해보고, 작동원리를 복습해보자.
http 요청 추가
ajax.http 파일에서 요청들을 추가한다. 참고로 http 파일에서 요청들을 여러 개 추가하기 위해서는 구분자로 주석문의 header인 ###을 넣어주어야 한다.
POST http://localhost:8080/api/login
Content-Type: application/json
X-Requested-With: XMLHttpRequest
{
"username" : "user",
"password" : "1111"
}
###
POST http://localhost:8080/api/login
Content-Type: application/json
X-Requested-With: XMLHttpRequest
{
"username" : "manager1",
"password" : "1111"
}
###
GET http://localhost:8080/api/messages
Content-Type: application/json
X-Requested-With: XMLHttpRequest
익명 사용자로 접근
인증 받지 않고 바로 /api/messages에 접근해본다. ExceptionTranslationHandler에 break point를 걸면, AccessDeniedException이 발생한다. 따라서 else if문에 걸려서 authentication 객체를 가져온다.
그 다음 if문에서 isAnonymous에 의해 else문으로 이동하고, sendStartAuthentication 메서드를 실행한다.
SendStartAuthentication
여기서는 결국 authenticationEntryPoint의 commence 메서드를 실행한다.
protected void sendStartAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, AuthenticationException reason) throws ServletException, IOException {
SecurityContextHolder.getContext().setAuthentication((Authentication)null);
this.requestCache.saveRequest(request, response);
this.logger.debug("Calling Authentication entry point.");
this.authenticationEntryPoint.commence(request, response, reason);
}
따라서 설정해줬던대로 UnAuthorized 응답이 온다.
인가 실패 : USER로 로그인 후 /api/messages 접근
분기처리는 위에서 살펴봤으니 코드만 간단하게 살펴보면 된다. AccessDeniedException에서 걸리고, accessDeniedHandler.handle 메서드를 실행한다.
if (exception instanceof AuthenticationException) {
this.logger.debug("Authentication exception occurred; redirecting to authentication entry point", exception);
this.sendStartAuthentication(request, response, chain, (AuthenticationException)exception);
} else if (exception instanceof AccessDeniedException) {
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
if (!this.authenticationTrustResolver.isAnonymous(authentication) && !this.authenticationTrustResolver.isRememberMe(authentication)) {
this.logger.debug("Access is denied (user is not anonymous); delegating to AccessDeniedHandler", exception);
this.accessDeniedHandler.handle(request, response, (AccessDeniedException)exception);
} else {
this.logger.debug("Access is denied (user is " + (this.authenticationTrustResolver.isAnonymous(authentication) ? "anonymous" : "not fully authenticated") + "); redirecting to authentication entry point", exception);
this.sendStartAuthentication(request, response, chain, new InsufficientAuthenticationException(this.messages.getMessage("ExceptionTranslationFilter.insufficientAuthentication", "Full authentication is required to access this resource")));
}
}
Handler에서 설정해준대로 응답이 온다.
인가 성공 : MANAGER로 로그인
MANAGER로 로그인 후 접근 시, 인가에 성공하여 MessagesController까지 요청이 전달되고, 처리된 응답값이 반환된다.
참조
1) 인프런 - 스프링 시큐리티 - Spring Boot 기반으로 개발하는 Spring Security - 정수원님 강의
https://www.inflearn.com/course/%EC%BD%94%EC%96%B4-%EC%8A%A4%ED%94%84%EB%A7%81-%EC%8B%9C%ED%81%90%E