Spring

Spring Security + Rest

일태우 2020. 2. 19. 22:44

Spring Security는 기본적으로 인증 과정에서의 결과를 redirect로 처리합니다.

  • 인증 성공 시 success page
  • 인증 실패 시 failure page
  • 비인가 접근 시 authentication page

크게 세가지의 행동에 대한 처리를 page redirect가 아닌 response를 반환하게 만들면 됩니다.

이러한 컨셉을 잡고 구현해 나가봅시다.

 

모든 소스는 https://github.com/lteawoo/RestSpringSecurity에서 확인 하실 수 있습니다.

 

Spring Security 기본 인증의 경우

UsernamePasswordAuthenticationFilter에서 formlogin 요청을 필터링하여 처리합니다.

이 클래스는 AbstractAuthenticationProcessingFilter를 구현하고 있습니다. Security의 인증프로세스 과정은 해당 필터를 구현하여 진행합니다.

import java.io.IOException;

import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;

import org.springframework.security.authentication.AuthenticationServiceException;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.authentication.AbstractAuthenticationProcessingFilter;
import org.springframework.security.web.util.matcher.RequestMatcher;

import com.fasterxml.jackson.databind.ObjectMapper;

import kr.taeu.restsecurity.member.dto.SignInRequest;

public class RestAuthenticationFilter extends AbstractAuthenticationProcessingFilter {

	public RestAuthenticationFilter(RequestMatcher requiresAuthenticationRequestMatcher) {
		super(requiresAuthenticationRequestMatcher);
	}

	@Override
	public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response)
			throws AuthenticationException, IOException, ServletException {
		if(!request.getMethod().equals("POST")) {
			throw new AuthenticationServiceException(
					"Authentication method not supported: " + request.getMethod());
		}
		
		/* ObjectMapper 사용시 기본생성자 필요 */
		SignInRequest signInRequest = new ObjectMapper().readValue(request.getReader(), SignInRequest.class);
		
		UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(signInRequest.getEmail().getValue(), signInRequest.getPassword().getValue());
		
		return getAuthenticationManager().authenticate(authentication);
	}

}

UsernamePasswordAuthenticationFilter에서는 request에서 getParameter 메서드로 아이디와 비밀번호를 추출하지만 rest 에서는 json방식으로 요청을 하므로 ObjectMapper를 통해 request에서 추출하도록 구현합니다.

 

import org.springframework.context.annotation.Bean;
import org.springframework.http.HttpMethod;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.builders.WebSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.web.util.matcher.AntPathRequestMatcher;

import kr.taeu.restsecurity.global.security.rest.RestAuthenticationFailuerHandler;
import kr.taeu.restsecurity.global.security.rest.RestAuthenticationSuccessHandler;
import kr.taeu.restsecurity.global.security.rest.filter.RestAuthenticationFilter;

@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter{
	
	@Bean
	public RestAuthenticationFilter restAuthenticationFilter() throws Exception {
		RestAuthenticationFilter restAuthenticationFilter = new RestAuthenticationFilter(new AntPathRequestMatcher("/member/signin", HttpMethod.POST.name()));
		restAuthenticationFilter.setAuthenticationManager(this.authenticationManager());
		restAuthenticationFilter.setAuthenticationFailureHandler(new RestAuthenticationFailuerHandler());
		restAuthenticationFilter.setAuthenticationSuccessHandler(new RestAuthenticationSuccessHandler());
		return restAuthenticationFilter;
	}
	
	@Override
	protected void configure(HttpSecurity http) throws Exception {
		http.authorizeRequests()
				.antMatchers("/member/signup", "/member/signin").anonymous()
				.anyRequest().authenticated()
			.and()
			.exceptionHandling()
				.authenticationEntryPoint(new RestAuthenticationEntryPoint())
			.and()
			.formLogin().disable()
			.csrf().disable();
	}
	
	@Override
	public void configure(WebSecurity web) throws Exception {
		web.ignoring().antMatchers("/v2/api-docs",
                "/configuration/ui",
                "/swagger-resources/**",
                "/configuration/security",
                "/swagger-ui.html",
                "/webjars/**",
                "/h2-console/**",
                "/js/**");
	}

필터를 위와 같이 Bean으로 등록하여 줍니다. /member/sigin으로 POST 요청이 들어오면 필터링 될 것 입니다.

이제 인증 실패, 성공의 경우의 처리를 해야합니다.

 

AuthenticationSuccessHandlerAuthenticationFailuerHandler 인터페이스를 구현하면 됩니다.

import java.io.IOException;

import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;

import org.springframework.security.core.Authentication;
import org.springframework.security.web.DefaultRedirectStrategy;
import org.springframework.security.web.RedirectStrategy;
import org.springframework.security.web.authentication.AuthenticationSuccessHandler;

public class RestAuthenticationSuccessHandler implements AuthenticationSuccessHandler{

	@Override
	public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response,
			Authentication authentication) throws IOException, ServletException {
		RedirectStrategy redirectStrategy = new DefaultRedirectStrategy();
	    redirectStrategy.sendRedirect(request, response, "/member/welcome");
	}
}

 

인증 성공의 경우 어떻게 처리할지는 서비스마다 다르겠지만 저는 테스트로 welcome을 호출하여 화면에 로그인한 유저정보를 뿌려주겠습니다.

RedirectStrategy를 이용하여 별다른 처리없이 /member/welcome으로 요청을 리디렉션 해줍시다.

	@GetMapping("/welcome")
	public String welcome(Authentication authentication) {
		return authentication.getName() + " hello!";
	}

 

member/welcome의 경우는 위와 같이 구현하면 인증정보를 뿌려줄 수 있습니다.

 

import java.io.IOException;

import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;

import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.authentication.AuthenticationFailureHandler;

import com.fasterxml.jackson.databind.ObjectMapper;

import kr.taeu.restsecurity.global.error.ErrorCode;
import kr.taeu.restsecurity.global.error.ErrorResponse;
import kr.taeu.restsecurity.member.exception.EmailNotFoundException;
import kr.taeu.restsecurity.member.exception.InvalidPasswordException;
import lombok.extern.slf4j.Slf4j;

/*
 * 인증 실패에 대한 전략
 */
@Slf4j
public class RestAuthenticationFailuerHandler implements AuthenticationFailureHandler {
	private ObjectMapper objectMappger = new ObjectMapper();
	
	@Override
	public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response,
			AuthenticationException exception) throws IOException, ServletException {
		ErrorResponse er = null;
		
		if(EmailNotFoundException.class == exception.getClass()) {
			er = new ErrorResponse(ErrorCode.REQUEST_CONFILICT_EXCEPTION, exception);
			response.setStatus(HttpStatus.CONFLICT.value());
		} else if (InvalidPasswordException.class == exception.getClass()) {
			er = new ErrorResponse(ErrorCode.REQUEST_CONFILICT_EXCEPTION, exception);
			response.setStatus(HttpStatus.CONFLICT.value());
		} else {
			er = new ErrorResponse(ErrorCode.REQUEST_CONFILICT_EXCEPTION, exception);
			response.setStatus(HttpStatus.CONFLICT.value());
		}
		
		response.setContentType(MediaType.APPLICATION_JSON_VALUE);
		response.getOutputStream().println(objectMappger.writeValueAsString(er));
	}
}

인증 실패의 경우는 AuthenticationException을 상속받는 예외별로 처리해두면 좋습니다. 인증 과정에서의 예외는 대부분 구현되어 있습니다.

저같은 경우는 ErrorResponse를 구현해고, UsernameUsernameNotFoundException을 상속받아 EmailNotFoundException를 만들어 처리해두었습니다.

 

예외별로 HttpStatus는 프론트엔드와의 협의로 바뀔수 있으니 참고만 해주세요.

 

비인가 접근시는 AuthenticationEntryPoint를 구현해주면 됩니다.

import java.io.IOException;

import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;

import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.security.authentication.InsufficientAuthenticationException;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.AuthenticationEntryPoint;

import com.fasterxml.jackson.databind.ObjectMapper;

import kr.taeu.restsecurity.global.error.ErrorCode;
import kr.taeu.restsecurity.global.error.ErrorResponse;

public class RestAuthenticationEntryPoint implements AuthenticationEntryPoint{
	private ObjectMapper objectMappger = new ObjectMapper();
	
	@Override
	public void commence(HttpServletRequest request, HttpServletResponse response,
			AuthenticationException authException) throws IOException, ServletException {
		ErrorResponse er = null;
		
		if(InsufficientAuthenticationException.class == authException.getClass()) {
			er = new ErrorResponse(ErrorCode.UNAUTHORIZED, authException);
			response.setStatus(HttpStatus.UNAUTHORIZED.value());
		}
		
		response.setContentType(MediaType.APPLICATION_JSON_VALUE);
		response.getOutputStream().println(objectMappger.writeValueAsString(er));
	}
	
}

여러 경우가 있겠습니다만(권한이 여러개인 경우 접근제한, 비인가된 접근 등)

테스트로 구현한 경우는 인증에 대한 신뢰가 가지 않는 경우(InsufficientAuthenticationException)를 통틀어 처리하였습니다. 더 세분화 하고 싶은 경우에는 AccessDecisionManager의 커스터마이징을 고려하면 될 것 같습니다.

 

 

'Spring' 카테고리의 다른 글

Spring Jpa - Query DSL + Gradle 6 설정  (3) 2020.09.21
Spring Jpa - Paging api 처리하기  (0) 2020.09.18
Spring Data Jpa Auditing  (0) 2020.07.29
ApplicationContext와 Singleton  (0) 2020.07.29
STS4 + Spring Boot + Gradle 기본 세팅하기  (0) 2019.12.23