Spring Security + JWT 순서

jun ho Jeon·2024년 2월 23일
0

Spring

목록 보기
1/1
post-thumbnail

Spring Security 와 JWT 를 사용하였을 때 전체적인 순서를 알아보자.


💡 Spring Security 란?

  • Spring Security는 Spring 애플리케이션의 보안(인증과 권한, 인가 등)을 담당하는 스프링 하위 프레임워크이다.

Spring Security 의 동작과정

전체적인 프로세스는 다음과 같다.

  1. Client가 어플리케이션에 요청을 보내면, Servlet Filter에 의해서 Security Filter로 Security 작업이 위임되고 여러 Security Filter 중에서 UsernamePasswordAuthenticationFilter (Username and Password Authentication 방식에서 사용하는 AuthenticationFilter)에서 인증을 처리한다.

  2. UsernamePasswordAuthenticationFilter (이하AuthenticationFilter) 는 Servlet 요청 객체 (HttpServletRequest) 에서 username과 password를 추출해 UsernameAuthenticationToken (이하 인증 객체)을 생성한다.

  3. AuthenticationFilter는 AuthenticationManager (구현체 : ProviderManager) 에게 인증 객체를 전달한다.

  4. ProviderManager는 인증을 위해 AuthenticationProvider에게 인증 객체를 전달한다.

  5. AuthenticationProvider는 전달받은 인증 객체의 정보 (일반적으로 사용자 아이디) 를 UserDetailsService에 넘겨준다.

  6. UserDetailsService는 전달 받은 사용자 정보를 통해 DB에서 알맞는 사용자를 찾고 이를 기반으로 UserDetails객체를 만듭니다.

  7. 사용자 정보와 일치하는 UserDetails객체를 AuthenticationProvider에 전달합니다.

  8. AuthenticationProvider은 전달받은 UserDetails를 인증하고, 성공하면 ProviderManager에게 권한 (Authorities) 을 담은 검증된 인증 객체를 전달합니다.

  9. ProviderManager는 검증된 인증 객체를 AuthenticationFilter에게 전달합니다. (event 기반 으로 전달)

  10. AuthenticationFilter는 검증된 인증 객체를 SecurityContextHolder의 SecurityContext에 저장합니다.

위의 과정이 일반적인 Spring Security의 과정이다.

하지만 나의 경우 Jwt를 사용하였기 때문에 그 순서가 조금 달라진다.

일단 우리가 Spring Security를 프로젝트에 적용해서 실행하면 아래와 같은 이미지를 보게된다.

Spring Security는 기본적으로 어플리케이션에 리소스를 요청할 때 접근 권한이 없는 경우 위 사진과 같은 로그인 폼으로 보내지게 된다.

이런 역할을 하는 필터가 UsernamePasswordAuthenticationFilter 이다.

하지만 Jwt를 사용할 것이기 때문에 UsernamePasswordAuthenticationFilter 전에 Jwt 관련 커스텀 필터 JwtAuthenticationFilter를 사용하여 인증 및 권한처리를 하였다.


아래 코드와 설명들을 통하여 Jwt를 사용하였을 때의 순서를 알아보자!

SecurityConfig

// Spring Security 관련 설정들을 하는 Configuration 클래스

fun filterChain(http: HttpSecurity): SecurityFilterChain {
        http
            .httpBasic { it.disable() }하기 때문에 비활성화
            .csrf { it.disable() }
            .sessionManagement {
                it.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
            }
            .authorizeHttpRequests {
                it.requestMatchers("/api/member/signup", "/api/member/login").anonymous()
                    .requestMatchers("/api/member/**").hasRole("JUNHO")
                    .anyRequest().permitAll()
            }
            .addFilterBefore(
                JwtAuthenticationFilter(jwtTokenProvider), //커스텀 필터 추가
                UsernamePasswordAuthenticationFilter::class.java 
            ) 

        return http.build()
    }



JwtAuthenticationFilter

// Jwt가 유효한 토큰인지 인증하기 위한 Filter이다.

override fun doFilter(request: ServletRequest?, response: ServletResponse?, chain: FilterChain?) {
        try {
            val token = resolveToken(request as HttpServletRequest, "Authorization")
            if (token != null && jwtTokenProvider.validateToken(token)) {
                // 토큰정보 추출하여 authentication 에 저장
                val authentication = jwtTokenProvider.getAuthentication(token)
                // SecurityContextHolder 의 getContext 에 토큰 정보 저장
                SecurityContextHolder.getContext().authentication = authentication
            }
        } catch (e: ExpiredJwtException) {
            reissueAccessToken(request as HttpServletRequest, response as HttpServletResponse, e)
        } catch (e: Exception) {
            request?.setAttribute("exception", e)
        }

        chain?.doFilter(request, response)


    }

커스텀 필터(JwtAuthenticationFilter)에서 인증 및 권한 작업을 진행할 것이기 때문에 AuthenticationManager를 사용하지 않고 JwtTokenProvider를 통해서 인증 후 SecurityContextHolder에 인증정보를 저장하였다.

JwtTokenProvider.kt

// Jwt Token을 생성, 인증, 권한 부여, 유효성 검사 등의 다양한 기능을 제공하는 클래스

...
fun createToken(authentication: Authentication): TokenInfo

fun getAuthentication(token: String): Authentication

fun validateToken(token: String): Boolean
...

인증 기능을 수행할 JwtTokenProvider를 만들었으면 JwtTokenProvider가 제공한 사용자 정보로 UserDetails를 만들어줄 커스텀한 UserDetailsService를 만들어야 한다.

CustomUserDetailsService

// 인터페이스인 UserDetailsService를 상속받아 작성한 클래스로 JwtTokenProvider가 제공한 사용자 정보를 이용하여 DB에서 알맞은 사용자 정보를 가져와 UserDetails 생성

@Service
class CustomUserDetailsService(
    private val memberRepository: MemberRepository,
    private val passwordEncoder: PasswordEncoder,
) : UserDetailsService {

    override fun loadUserByUsername(username: String): UserDetails = memberRepository.findByLoginId(username)
        ?.let { createUserDetails(it) }
        ?: throw UsernameNotFoundException("해당하는 유저를 찾을 수 없습니다.")

    private fun createUserDetails(member: Member): UserDetails =
        // CustomUser 클래스를 통해 UserDetails 만들기
        CustomUser(
            member.loginId,
            passwordEncoder.encode(member.password),
            member.memberRole!!.map { SimpleGrantedAuthority("ROLE_${it.role}") }
        )
}

UserDetailsService는 Spring Security에서 유저의 정보를 불러오기 위해서 구현해야하는 인터페이스로 기본 오버라이드 메서드 loadUserByUsername이다. 인터페이스이기 때문에 상속받을 클래스를 만들어서 메소드 오버라이드 하여 사용해야 한다.

CustomUser

// User를 상속받아 작성한 클래스로 기본 userName, password, authorities 제공한다. 추가로 필요한 속성을 추가하기 위해 만듦

class CustomUser (
    userName: String, // 기존 속성 1
    password: String, // 기존 속성 2
    authorities: Collection<GrantedAuthority> // 기존 속성 3
) : User(userName, password, authorities)

나의 경우 추가적으로 필요한 속성이 없었지만, 대부분의 경우 Spring Security의 기본 UserDetails로는 필요한 정보를 모두 담을 수 없기에 아래와 같은 CustomUser를 구현하여 사용한다.



이제 service 로직을 살펴보자.

fun login(loginDto: LoginDto): Map<String, String> {
        val authenticationToken = UsernamePasswordAuthenticationToken(loginDto.loginId, loginDto.password)
        val authentication = authenticationManagerBuilder.`object`.authenticate(authenticationToken)
        // access/refresh token 생성
        val tokenInfo = jwtTokenProvider.createToken(authentication)
        val member = memberRepository.findByLoginId(loginDto.loginId)?: throw InvalidInputException("id", "로그인 id(${loginDto.loginId}가 존재하지 않는 유저입니다.)")
        val memberRefreshToken = memberRefreshTokenRepository.findByIdOrNull(loginDto.loginId)

        // refresh token 이 null 이  아니면 갱신, null 이면 새로 발급한 refresh token db에 저장
        memberRefreshToken?.updateRefreshToken(tokenInfo.refreshToken) ?: memberRefreshTokenRepository.save(MemberRefreshToken(member!!, tokenInfo.refreshToken))

        val tokenInfoMap = mapOf<String, String>("grantType" to tokenInfo.grantType, "accessToekn" to tokenInfo.acceesToken, "refreshToken" to tokenInfo.refreshToken)
        return tokenInfoMap
    }

위 코드는 로그인시 id와 pw를 뽑아서 UsernamePasswordAuthenticationToken 객체로 생성한다.

그리고 AuthenticationManager 를 담고 있는 authenticationManagerBuilder 의 authenticate 메소드를 실행하면 내부 로직으로 인하여 (이 부분은 딥하게 보지 않아서 정확히 모름) UserDetailsService 를 상속받은 CustomUserDetailsService 의 loadUserByUsername 메소드 호출하게 된다.

loadUserByUsername 의 반환값인 UserDetails 객체를 다시 Authentication 타입의 객체로 반환하여 돌려준다.

받은 Authentication 타입의 객체를 이용하여 JwtTokenProvider 에 정의된 토큰생성 메소드를 호출하여 토큰을 생성한다.

정리하면
🟡 토큰 발급이 안되어있을 경우

  1. 클라이언트 요청
  2. SecurityConfig에 등록된 JwtAuthenticationFilter 호출
  3. 토큰정보가 없기 때문에 다음 필터 호출

🟡 토큰을 발급하는 경우(로그인)

  1. 클라이언트 요청
  2. SecurityConfig에 등록된 JwtAuthenticationFilter 호출
  3. 토큰정보가 없기 때문에 다음 필터 호출
  4. 로그인 url은 SecurityConfig에 예외처리 하였기 때문에 필터에 걸리지 않는다.
  5. 사용자의 로그인id, pw를 사용하여 UsernamePasswordAuthenticationToken 객체 생성
  6. authenticationManagerBuilder를 이용하여 CustomUserDetailsService 의 loadUserByUsername 메소드 호출
  7. loadUserByUsername 의 반환값인 UserDetails 객체를 다시 Authentication 타입의 객체로 반환
  8. Authentication을 이용하여 토큰 생성

🟡 토큰 발급이 되어있는 경우

  1. 클라이언트 요청
  2. SecurityConfig에 등록된 JwtAuthenticationFilter 호출
  3. JwtAuthenticationFilter 에서 토큰 유효성 검사 후 통과되면 다음 필터 호출
profile
데부업

0개의 댓글