필터, AOP 활용 인증, 인가 기능 설계 - ttasjwi/board-system GitHub Wiki

사용자 인증은 어디서?

image

  • Spring MVC 의 모든 웹 요청은 DispatcherServlet 을 거치게되고, 이 앞에 서블릿 필터가 있을 경우 서블릿 필터를 먼저 거친다.
  • 서블릿 필터 기반의 인증/인가 프레임워크인 Spring Securiy를 활용하여 인증/인가 기능을 구현한다.

Spring Security 활용 인증

image

  • spring-boot-starter-security 를 추가하면 스프링부트가 서블릿 기반의 필터체인을 기본으로 등록해준다.
  • 개발자가 커스텀 설정을 통해 커스텀 시큐리티 필터체인을 빈등록하면, 이를 우선시하여 사용하게된다.

internal class AccessTokenAuthenticationFilter(
    private val accessTokenParsePort: AccessTokenParsePort,
    private val timeManager: TimeManager,
) : OncePerRequestFilter() {

    private val bearerTokenResolver: BearerTokenResolver = BearerTokenResolver()

    public override fun doFilterInternal(
        request: HttpServletRequest,
        response: HttpServletResponse,
        filterChain: FilterChain
    ) {

        // 이미 인증됐다면 통과
        if (isAuthenticated()) {
            filterChain.doFilter(request, response)
            return
        }

        // 헤더를 통해 토큰을 가져옴. 없다면 통과
        val tokenValue = bearerTokenResolver.resolve(request)
        if (tokenValue == null) {
            filterChain.doFilter(request, response)
            return
        }

        // 토큰값을 통해 인증
        val authentication = attemptAuthenticate(tokenValue)

        // 인증 결과를 SecurityContextHolder 에 저장
        saveAuthenticationToSecurityContextHolder(authentication)

        // 통과
        try {
            filterChain.doFilter(request, response)
        } finally {
            SecurityContextHolder.getContextHolderStrategy().clearContext()
        }
    }

    private fun isAuthenticated() = SecurityContextHolder.getContextHolderStrategy().context.authentication != null

    private fun attemptAuthenticate(tokenValue: String): AuthUserAuthentication {
        val accessToken = accessTokenParsePort.parse(tokenValue)
        val currentTime = timeManager.now()
        accessToken.throwIfExpired(currentTime)
        return AuthUserAuthentication.from(accessToken.authUser)
    }

    private fun saveAuthenticationToSecurityContextHolder(authentication: AuthUserAuthentication) {
        val securityContext = SecurityContextHolder.getContextHolderStrategy().createEmptyContext()
        securityContext.authentication = authentication
        SecurityContextHolder.getContextHolderStrategy().context = securityContext
    }
}
  • 우리 서비스에서는 커스텀 필터인 AccessTokenAuthenticationFilter 를 스프링 시큐리티 필터체인에 추가하였다.
  • 이 곳에서 액세스토큰 기반의 인증을 이루어지게 하고, Spring Security 맥락에 맞게, SecurityContextHolder 에 사용자 인증 정보를 저장하게 한다.
    • SecurityContextHolder 는 ThreadLocal 기반의 스레드 단위 인증 정보 저장소이다.
    • 사용자 요청이 들어올 때마다 ThreadLocal 에 사용자 인증정보를 저장해두고 해당 요청-응답 사이클 내에서 인증정보를 사용할 수 있게한다.

AOP 활용 인가

@Aspect
class MethodAuthorizationAspect(
    private val authUserLoader: AuthUserLoader
) {

    companion object {
        private val ADMIN_ROLES = listOf(Role.ADMIN, Role.ROOT)
    }

    @Before("@annotation(com.ttasjwi.board.system.common.annotation.auth.PermitAll)")
    fun checkPermitAll() {

    }

    @Before("@annotation(com.ttasjwi.board.system.common.annotation.auth.RequireAuthenticated)")
    fun checkAuthenticated() {
        authUserLoader.loadCurrentAuthUser() ?: throw UnauthenticatedException()
    }

    @Before("@annotation(com.ttasjwi.board.system.common.annotation.auth.RequireAdminRole)")
    fun checkAdminRole() {
        val authUser = authUserLoader.loadCurrentAuthUser() ?: throw UnauthenticatedException()

        if (authUser.role !in ADMIN_ROLES) {
            throw AccessDeniedException()
        }
    }

    @Before("@annotation(com.ttasjwi.board.system.common.annotation.auth.RequireRootRole)")
    fun checkRootRole() {
        val authUser = authUserLoader.loadCurrentAuthUser() ?: throw UnauthenticatedException()

        if (authUser.role != Role.ROOT) {
            throw AccessDeniedException()
        }
    }
}
  • 각 API 마다, 사용자의 권한을 통제해야하는 경우가 있다.
  • 어떤 API는 인증된 사용자만 접근 가능하고, 어떤 API는 누구나 접근 가능해야하고, 어떤 API는 관리자만 접근 가능해야한다.
  • 하지만 이런 로직을 각 컨트롤러에서 직접 구현하는 것은 불편하고, 비슷한 로직을 여러 곳에서 공통적으로 요청 처리 전에 처리해야한다.
  • 이런 공통 횡단관심사를 해결하기 위해 spring-boot-starter-aop 에서 제공하는 AOP 기능을 활용하여 인가작업을 처리했다.

AOP란?

image

  • **AOP(관점 지향 프로그래밍)**은 여러 함수에서 공통적으로 관심을 갖는 **횡단 관심사(Cross-Cutting Concerns)**를 핵심 비즈니스 로직과 분리하여 모듈화할 수 있는 프로그래밍 방법론이다.
  • 핵심 로직의 코드를 복잡하게 만들지 않고도, 트랜잭션 처리, 로깅, 보안 검사와 같은 공통적인 부가 기능을 손쉽게 추가할 수 있다.
    • 예) 로직 시작 전에 트랜잭션 시작, 종료 후에 커밋/롤백
    • 예) 로직 시작 전에 로깅 시작, 종료 후에 로깅 종료
    • 예) 로직 시작 전에 사용자 접근 권한 체크
  • spring-boot-starter-aop 의존성을 추가하면, Spring은 빈 후처리기(BeanPostProcessor) 를 통해 개발자의 부가 로직(Advice)과 핵심 로직을 조합한 프록시 객체를 생성해 빈으로 등록한다. 이 프록시 객체는 Advice를 로직 실행 전후에 자동으로 실행시켜준다.

Spring AOP 용어

  • @AspectSpring AOP에서 Aspect(관점) 를 정의할 때 사용하는 애너테이션이다.
  • 즉, @Aspect하나 이상의 Advice(부가 로직)Pointcut(적용 지점) 을 포함하는 모듈이라는 점을 알리는 마커 어노테이션이다.
  • 주요 용어
    • Aspect: 부가 기능(Advice) + 포인트컷(Pointcut)을 함께 묶은 모듈
      • 실전적 의미 : @Aspect 클래스
    • Advice: 실제 실행될 부가 로직
      • 실전적 의미: @Before, @After, @Around 등이 걸려있는 메서드
    • `Pointcut: Advice가 어디에 적용될지 결정하는 조건
      • 실전적 의미: @Before, @After, @Around 어노테이션 속성값으로 기술되는 표현식 -> execution(* com.example..*(..))
    • JoinPoint: Advice가 적용될 수 있는 실제 실행 지점
      • 실전적 의미: 실제 메서드 실행 시점
    • Advisor: Spring AOP 내부 개념으로, Pointcut + Advice를 함께 포함한 객체 (프록시를 만들 때 사용됨)
      • 내부적으로 Spring이 사용하는 구현체
  • Spring AOP는 프록시 기반이며, 이 프록시에 Advisor가 연결되어 Advice가 자동 실행된다.