[Spring Security] 권한 체크와 오류 처리(SecurityIntercepter, ExceptionTranslationFilter)

WOOK JONG KIM·2022년 12월 3일
0

패캠_java&Spring

목록 보기
82/103
post-thumbnail

SecurityIntercepter

필터에 마지막 단에 위치하며 스프링의 권한 체크 기능

중간에 플로우를 가로채서 검사

컨트롤러(서비스)에 들어오는 Request는 Intercepter가 가로채서 컨트롤러를 사용할 수 있는지 검증함

FilterSecurityInterceptor

SecurityInterceptor : AccessDecisionManager 를 통해 권한여부를 판단하고 통과시켜주거나 Deny

Security Intercepter가 필터에 있으면 Filter Security Intercepter에서 체크하고

메서드에서 선언한 권한들은 Method Security Intercepter에서 서블릿 단을 활용해 체크

Invocation, JointPoint는 AOP 개념을 따르기떄문에 Pointcut을 사용해서 적용 됨

개발자가 호출할 당시에 어떤 상태에서 호출했는지를 Invocation 객체에 담아서 전달

  • beforeInvocation : Security Config 에서 설정한 접근 제한을 체크
    -> 이 메서드 진입 시 SecurityMetaDataSource 에서 개발자가 지금 요청한 것에 대한 config를 가져다 달라고 함
    -> 이 때 config는 조사해보니 개발자가 실행한 요청은 permitAll()에 해당이 된다고 알려주게 됨
    -> 이 후 한명의 voter라도 access를 허용해준다면 리퀘스트는 무사히 통과 됨

  • finallyInvocation : RunAs 권한을 제거합니다(임시 권한 박탈)

  • afterInvocation : AfterInvocationManager 를 통해 체크가 필요한 사항을 체크, 특별히 설정하지 않으면 AfterInvocationManager 는 null
    (리퀘스트가 나갈때마다 검사할것이 있다면 사용)

Security Intercepter 바로 전단에 위치하는 것이 ExceptionTranslationFilter


ExceptionTranslationFilter

이 필터는 FilterSecurityInterceptor애플리케이션에서 올라오는 오류를 가로채 처리

AuthenticationExceptionAccessDeniedException만 처리
-> 그 밖의 오류는 보통 ControllerAdvice 를 이용해서 처리하는 것을 권장

디폴트로 해주는 역할이 많아서 커스터마이징 하는 경우는 드물다

LoginUrlAuthenticationEntryPoint
-> 이 에러는 인증을 안받고와서 생긴 에러니까 로그인을 다시해야 들어올수 있어

AccessDeniedHandler
-> 너가 누구인진 알겠는데 이 리소스를 사용할 권한은 없어

401 에러와 403 에러

  • 401 : 인증 실패

AuthenticationException

다시 로그인 해야 하므로 AuthenticationEntryPoint 로 처리를 넘김(commence 메서드를 통해 Redirect)
AuthenticationException이 발생했다고 서버가 반드시 401 에러를 내려보내는 것은 아님
-> 해당 에러를 401 오류로 처리하는 코드를 넣어야 함, 필요에 따라서는 403 오류코드로 처리하기도 한다

  • 403 : 권한 없음

AccessDeniedException

Anonymous 유저이거나 RememberMe 유저이면 다시 로그인 하도록 AuthenticationEntryPoint 로 처리를 넘김
그 밖의 유저는 권한없음 페이지로 이동하거나 권한없음 메시지를 받게 됨

private void doFilter(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
			throws IOException, ServletException {
		try {
			chain.doFilter(request, response);
		}
		catch (IOException ex) {
			throw ex;
		}
		catch (Exception ex) {
			// Try to extract a SpringSecurityException from the stacktrace
			Throwable[] causeChain = this.throwableAnalyzer.determineCauseChain(ex);
			RuntimeException securityException = (AuthenticationException) this.throwableAnalyzer
					.getFirstThrowableOfType(AuthenticationException.class, causeChain);
			if (securityException == null) {
				securityException = (AccessDeniedException) this.throwableAnalyzer
						.getFirstThrowableOfType(AccessDeniedException.class, causeChain);
			}
			if (securityException == null) {
				rethrow(ex);
			}
			if (response.isCommitted()) {
				throw new ServletException("Unable to handle the Spring Security Exception "
						+ "because the response is already committed.", ex);
			}
			handleSpringSecurityException(request, response, chain, securityException);
		}

일단 ExceptionTranslationFilter.doFilter를 들어오게 되면 request는 doFilter를 타고 그다음 필터와 서블릿을 통해 서비스를 수행하게 됨
-> 수행 도중 Exception 발생 시 AuthenticationException 인지 아니면 AccessDeniedException인지 검사 수행
-> 둘 중 하나이면 스프링 Exception으로 처리를 하고 아닌경우엔 rethrow

handleSpringSecurityException이 핵심

AuthenticationException에 의해 로그인을 해야할 상황에 놓은 사용자가 로그인을 할 시 접속하고자 했던 페이지로 Redirect을 해주어야 함
-> 이를 위해 리퀘스트 캐쉬를 저장해 두게됨

-> this.requestCache.saveRequest(request, response)
-> 저장 후 엔트리 포인트로 보내게 됨

AccessDeniedException의 경우 당연히 Anonymous 한 사용자라면 로그인을 하라고 리다이렉트, 그런데 RemeberMe 토큰을 가지고 있는 사용자 또한 재 로그인을 하게 함

ex) User 권한을 가진 Aaron이 있는데 이 사용자는 세션이 만료된 후 RemeberMe 토큰을 가지고 로그인을 하고, 이후 관리자 페이지에 접속하려했으나 로그인 페이지로 리다이렉트 됨

위의 문제 해결 예시

	@Bean
    PersistentTokenBasedRememberMeServices rememberMeServices(){
        PersistentTokenBasedRememberMeServices service =
                new PersistentTokenBasedRememberMeServices("hello",
                        spUserService,
                        tokenRepository()
                        ){
                    
                    // 여기서 Rememberme 토큰 발급
                    @Override
                    protected Authentication createSuccessfulAuthentication(HttpServletRequest request, UserDetails user) {
                        // 인증 받기 이전에 , 즉 this.remeberMeservices.autoLogin(request, response);
                        // 인풋으로서의 AuthenticationToken을 만들어서 넣어 준 것
                        return new UsernamePasswordAuthenticationToken(user.getUsername(), user.getPassword());
                    }
                };
        service.setAlwaysRemember(true);
        return service;
    }

비정상적 접근 프로세스 플로우

User 권한의 사용자가 관리자 페이지에 접근하려고 할 때

FilterSecurityIntercepter

public void invoke(FilterInvocation filterInvocation) throws IOException, ServletException {
		if (isApplied(filterInvocation) && this.observeOncePerRequest) {
			// filter already applied to this request and user wants us to observe
			// once-per-request handling, so don't re-do security checking
			filterInvocation.getChain().doFilter(filterInvocation.getRequest(), filterInvocation.getResponse());
			return;
		}
		// first time this request being called, so perform security checking
		if (filterInvocation.getRequest() != null && this.observeOncePerRequest) {
			filterInvocation.getRequest().setAttribute(FILTER_APPLIED, Boolean.TRUE);
		}
		InterceptorStatusToken token = super.beforeInvocation(filterInvocation);
		try {
			filterInvocation.getChain().doFilter(filterInvocation.getRequest(), filterInvocation.getResponse());
		}
		finally {
			super.finallyInvocation(token);
		}
		super.afterInvocation(token, null);
	}

이때 beforeInvocation에서는 오류가 나진 않음
-> 앞선 Security Config에서 anyRequest().authenticated가 되있으면 통과 시켜주기로 설정했기 때문

저 로직을 다 타고 나면 ExceptionTranslateFilter로 빠짐

ExceptionTranslateFilter.java

private void doFilter(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
			throws IOException, ServletException {
		try {
			chain.doFilter(request, response);
		}
		catch (IOException ex) {
			throw ex;
		}
		catch (Exception ex) {
			// Try to extract a SpringSecurityException from the stacktrace
			Throwable[] causeChain = this.throwableAnalyzer.determineCauseChain(ex);
			RuntimeException securityException = (AuthenticationException) this.throwableAnalyzer
					.getFirstThrowableOfType(AuthenticationException.class, causeChain);
			if (securityException == null) {
				securityException = (AccessDeniedException) this.throwableAnalyzer
						.getFirstThrowableOfType(AccessDeniedException.class, causeChain);
			}
			if (securityException == null) {
				rethrow(ex);
			}
			if (response.isCommitted()) {
				throw new ServletException("Unable to handle the Spring Security Exception "
						+ "because the response is already committed.", ex);
			}
			handleSpringSecurityException(request, response, chain, securityException);
		}

여기서 Exception Catch문을 타게 됨


Exception에 따라 리다이렉트 시킬 페이지를 커스텀화 해보기

SecurityConfig

				.exceptionHandling(error->
                        error
//                                .accessDeniedPage("/access-denied")
                                .accessDeniedHandler(new CustomDeniedHandler())
                                .authenticationEntryPoint(new CustomEntryPoint())
                )

CustomDenied handler

public class CustomDeniedHandler implements AccessDeniedHandler {
    @Override
    public void handle(HttpServletRequest request,
                       HttpServletResponse response,
                       AccessDeniedException accessDeniedException) throws IOException, ServletException
    {
        // 어떤 access-denied에 따라 handler를 다르게 한다면 ex) 에러1 -> 에러1 페이지로 에러2 -> 에러2 페이지로

        if(accessDeniedException instanceof YouCannotAccessUserPage){
            // 유저 접근 권한이 없습니다
            request.getRequestDispatcher("/access-denied").forward(request,response);
        }else{
            // 관리자 접근 권한이 없습니다
            request.getRequestDispatcher("/access-denied2").forward(request,response);
        }
    }
}

CustomEntryPoint

public class CustomEntryPoint implements AuthenticationEntryPoint {
    @Override
    public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException {

        request.getRequestDispatcher("/login-required")
                .forward(request,response);
    }
}

위 엔트리 포인트 설정 후 홈페이지에서 로그인 안한 사용자가 userpage에 접근 시 위에 설정한 html 페이지(login-required)가 나타남

Controller

	@GetMapping("/login-required")
    public String loginRequired(){ return "LoginRequired";}

    @GetMapping("/access-denied")
    public String accessDenied(){
        return "AccessDenied";
    }

    @GetMapping("/access-denied2")
    public String accessDenied2() { return "AccessDenied2";}

    @PreAuthorize("hasAnyAuthority('ROLE_USER')")
    @GetMapping("/user-page")
    public String userPage() throws YouCannotAccessUserPage
    {
        if(true){ // 접근시 바로 띄워서 확인하기 위해 True로 하였음
        throw new YouCannotAccessUserPage();
        }
        return "UserPage";
    }

YouCannotAccessUserPage

public class YouCannotAccessUserPage extends AccessDeniedException {

    public YouCannotAccessUserPage() {
        super("유저 페이지 접근 거부");
    }
}
profile
Journey for Backend Developer

0개의 댓글