본문 바로가기

IT/SPRING BOOT

Spring Security Authenticate에 대해 알아보자

이번 시간에는 Spring Authenticate Architecture에 대해 알아보고자 합니다.
Spring Security는 대부분의 Spring 프로젝트를 함에 있어 필수적으로 사용한 경험이 있으실 겁니다.

이 글에서는 간단한 예제를 통해 Spring Security에 대한 이해를 높이고자 작성 하였습니다.

1. 프로젝트 생성

Spring Boot 웹 종속성만 추가합니다..

Gradle

implementation("org.springframework.boot:spring-boot-starter-web")

http://localhost:80 으로 접속하면 "곰이야"라는 텍스트를 출력해 보겠습니다.

@SpringBootApplication
class ToyProjectApplication

fun main(args: Array<String>) {
    runApplication<ToyProjectApplication>(*args)
}

@RestController
class TestController {
  @GetMapping("/pet")
  fun pringTest(): String {
    return "곰이야"
  }
}

http://localhost:80/hello로 입력하면 보안에 문제없이 작동합니다.

어떻게 작동하는 걸까요?

Spring Boot Web은 Spring MVC하위 프레임워크와 내부 구성을 다룹니다.

Spring MVC는 들어오는 모든 HTTP요청을 DispatcherServlet 이라는 단일 서블릿으로 보내기 때문입니다.

DispatcherServlet은 이러한 모든 HTTP 요청을 EndPoint를 정의하는 컨트롤러 클래스에 위임하는 서블릿입니다.

예전에는 새 페이지에 대한 새 서블릿 클래스를 정의했지만 Spring 프레임워크는 단일 서블릿 개념을 도입하고 DispatcherServlet을 구현했기 때문에 비즈니스 로직에만 집중할 수 있습니다.

즉 Dispatcherservlet을 보호하면 애플리케이션도 보호됩니다.

Spring Security는 HTTP요청이 DispatcherServlet에 도달하기 전에 필터 클래스를 추가하여 작동합니다.

즉 들어오는 모든 요청은 이러한 필터 클래스를 하나씩 방문합니다. 이렇게 하면 요청이 DispatcherServlet에 도달한 다음 컨트롤러에 도달하기 전에 인증 및 인가 상태를 확인할 수 있습니다.

이것이 Spring Security가 하는 일입니다.

필터 체인을 설명하기 전에 인증 및 인가의 개념을 알아보고 가겠습니다.

  • 인증 (Authentication)
    들어오는 모든 사용자는 애플리케이션에서 식별해야 합니다. 기본적으로 로그인으로 사용자가 누구인지 알아야합니다. 로그인이 된 사용자를 인증합니다.
  • 인가 (Authorization)
    사용자를 인증하면 인증된 사용자가 애플리케이션에서 사용할 수 있는 것을 제한할 수 있어야 합니다.
    권한 부여라고 생각하시면 되겠습니다.

- 필터 체인

아래 표에서 Spring Security의 기본 필터 순서를 볼 수 있습니다. Spring Security 공식 문서에서는 아래와 같은 필터 순서를 사용할 것을 권장하고 있습니다.

  • UsernamePasswordAuthenticationFilter : 인증 매커니즘이 해당 필터에서 시작합니다.
    HTTP메서드가 POST인 요청 본문에서 사용자 이름과 암호를 찾으려고 시도합니다. 인증 관리자를 호출하여 인증을 시도합니다.
  • BasicAuthenticationFilter : 기본 인증 헤더를 찾아 인증관리자를 호출하여 사용자 인증을 시도하는 필터입니다.
  • FilterSecurityInterceptor : 기본적으로 이 필터는 애플리케이션에서 인증을 제어합니다.

보시다시피 인증 및 권한 부여 목적을 위한 많은 필터 클래스가 있습니다.

그리고 기본동작은 WebSecurityConfigureAdapter클래스를 확장합니다.

WebSecurityConfigureAdapter클래스의 applyDefaultConfiguration() 및 configure() 메서드

2. Spring Security 의존성 추가

Spring Security 종속성을 추가해 보겠습니다.

Gradle

implementation("org.springframework.boot:spring-boot-starter-security")

애플리케이션을 다시 실행해보면 로그에 생성된 비밀번호가 표시됩니다.

Spring Security 동작흐름

  1. 인증요청이 있으면 Spring Security Filter Chain으로 이동하고 모든 필터를 하나씩 방문하고 인증필터에 도달합니다.
  2. 인증 관리자를 호출합니다.
    인증관리자의 책임은 이러한 모든 공급자를 거치고 사용자 인증에 적어도 한 번은 성공하도록 시도하는 것입니다.
  3. 인증 공급자는 사용자 정보 서비스와 통신하여 사용자를 가져오고 사용자가 주어진 자격 증명으로 존재하는 경우 성공 상태를 반환합니다. 실패하면 예외를 던저야 합니다. 이를 통해 Spring Security는 이 특정 인증 공급자가 사용자를 찾지 못했다는 것을 알고 있습니다.

기본 사용자와 생성된 비밀번호로 새 인증 요청 다이어 그램

  • 새로운 인증 요청은 Spring Security Filter Chain으로 이동합니다.
  1. 기본 폼 로그인을 위해 설계된 기본 필터인 UsernamePasswordAuthenticationFilter입니다.
  2. UsernamePasswordAuthenticationFilter에서 사용자 이름과 비밀번호를 추출하여 인증 관리자에게 보냅니다.
  3. 인증관리자는 사용자이름과 암호를 DaoAuthenticationProvider기본 공급자에게 보냅니다.
  4. 공급자는 기본 사용자 세부 정보 서비스인 InMemoryUserDetailsManager로 이동하여 주어진 자격 증명으로 사용자가 존재하는지 확인합니다.

UsernamePasswordAuthenticationFilter에 attemptAuthentication()프로세스를 확인해 보시면 이해가 되실겁니다.


Spring Security 기본 인증 필터인 UsernamePasswordAuthenticationFilter

3. 사용자 지정 인증 공급자 - JWT 토큰 기반

지금까지는 애플리케이션 보안을 위해 기본 클래스를 사용했습니다. 지금부터 간단한 사용자 보안 설정을 해보겠습니다.

제 프로젝트에서 개념적인 부분을 설명드리기위해 가져온 소스이니 그냥 보고만 가주시길 부탁드립니다.

// login.kt

@RestController
@RequestMapping("/sign-in")
class SignInController(
    val signInService: SignInService
) {

    @PostMapping("/admin")
    fun signInAdmin(
        @RequestParam clientId: String,
        @RequestParam clientSecret: String
    ): SignInRes = signInService
        .signIn(clientId, clientSecret, "", Role.ROLE_ADMIN)
        .let(SignInRes::create)
}
  • clientId ,clientSecret을 객체로 래핑하여 인증 관리자에게 보냅니다.
  • 인증 관리자가 예외를 발생시키지 않으면 인증이 성공했음을 의미하므로 새로 생성된 JWT토큰을 사용자에게 반환합니다.

SignInService.kt

@Service
class SignInService(
    val authenticationFactory: AuthenticationFactory,
    val tokenProvider: TokenProvider
) {
    fun signIn(
        clientId: String,
        clientSecret: String,
        fcmKey: String,
        role: Role
    ): String {
        val authentication = authenticationFactory.getAuthentication(role)
        return tokenProvider.getToken(authentication, clientId, clientSecret, fcmKey) ?:
        throw ResponseStatusException(
            HttpStatus.UNAUTHORIZED,
            "Unauthorized. Sign in Fail. id: $clientId"
        )
    }
}

tokenProvider.getToken()에서 토큰을 반환합니다.

TokenProvider

interface TokenProvider {
    fun getToken(
        authentication: Authentication,
        clientId: String,
        clientSecret: String,
        fckKey: String
    ): String?
    fun createToken(authentication: Authentication): String  = ""
}

AdminTokenFilter.kt

class AdminTokenFilter(
    private val tokenVerifier: TokenVerifier
) : OncePerRequestFilter() {
    override fun doFilterInternal(
        request: HttpServletRequest,
        response: HttpServletResponse,
        filterChain: FilterChain
    ) {
        val adminId = FilterUtil.getAdminIdFromPath(request.requestURI)
        val decodedJWT = FilterUtil.getTokenFromHeader(request)!!
            .let {
                tokenVerifier
                    .verify(it)
            }

        val tokenAdminId = decodedJWT
            .getClaim("id")
            .asString()

        val role = decodedJWT
            .getClaim("role")
            .asString()

        if (adminId != tokenAdminId) {
            response.sendError(HttpServletResponse.SC_FORBIDDEN, "unauthorized")
            response.status = HttpServletResponse.SC_FORBIDDEN
            return
        }

        if (role != Role.ROLE_ADMIN.value) {
            response.sendError(HttpServletResponse.SC_FORBIDDEN, "unauthorized")
            response.status = HttpServletResponse.SC_FORBIDDEN
            return
        }

        filterChain.doFilter(request, response)
    }

    override fun shouldNotFilter(request: HttpServletRequest): Boolean {
        return !FilterUtil.checkIsAdminApiPath(request.requestURI)
    }
}

SecurityConfig.kt : Spring Security에 대한 설정을 해줍니다.

@Configuration
class SecurityConfig(
    val tokenVerifier: TokenVerifier
): WebSecurityConfigurerAdapter() {

    @Bean
    fun jwtTokenFilter() = AuthTokenFilter(tokenVerifier)

    @Bean
    fun adminTokenFilter() = AdminTokenFilter(tokenVerifier)

    override fun configure(http: HttpSecurity) {
        http
            .csrf()
                .disable()
            .httpBasic()
                .disable()
            .formLogin()
                .disable()
            .cors()
                .configurationSource {
                    val cors = CorsConfiguration()
                    cors.exposedHeaders = listOf("Content-Disposition")
                    cors.allowedOrigins = listOf("*")
                    cors.allowedMethods = listOf("*")
                    cors.allowedHeaders = listOf("*")
                    cors
                }
                .and()
            .sessionManagement()
                .sessionCreationPolicy(SessionCreationPolicy.STATELESS)
                .and()
            .authorizeRequests()
                .anyRequest()
                .permitAll()
                .and()

            .addFilterBefore(jwtTokenFilter(), BasicAuthenticationFilter::class.java)
            .addFilterBefore(adminTokenFilter(), BasicAuthenticationFilter::class.java)
            .addFilterBefore(sellerTokenFilter(), BasicAuthenticationFilter::class.java)
            .addFilterBefore(customerTokenFilter(), BasicAuthenticationFilter::class.java)
            .addFilterBefore(staffTokenFilter(), BasicAuthenticationFilter::class.java)
            .addFilterBefore(shopTokenFilter(), BasicAuthenticationFilter::class.java)
            .exceptionHandling()
                .authenticationEntryPoint(AuthenticationException())
    }
}

사용자 정의 보안 구성을 위해

fun adminTokenFilter() = AdminTokenFilter(tokenVerifier)을 추가해준 모습입니다.

 

정리

오늘은 Spring Security 동작흐름에 대해 알아 보았습니다. 어떤 프로젝트를 하든 인증은 기본적으로 구현되어야 하죠?

그래서 오늘 이 시간에는 Spring에서 보안 흐름은 어떻게 되는 지 알아보고 로그인 구현부를 간단하게 만들어 보았습니다.