꿈틀꿈틀 개발일기

Spring | JWT 인증 구현하기

by jeongminy

클라이언트의 요청

클라이언트가 매 요청시에 JWT를 보내게 되는데, 이때 해당 JWT를 검증하는 기능을 구현할 겁니다

일반적으로 클라이언트가 요청할 때 JWT는 아래와 같이 헤더의 Authorization  헤더를 통해 전달합니다.

{
  "Authorization": Bearer {JWT}
}

 

타입의 종류는 3가지가 있습니다.

 

Basic, Bearer, Digest는 모두 웹 애플리케이션에서 사용되는 인증 방식입니다. 이들은 클라이언트가 서버에 요청을 보낼 때, 사용자를 인증하기 위해 사용됩니다. 각각의 차이점은 다음과 같습니다:

 

1. Basic 인증:

사용자 이름과 비밀번호를 Base64로 인코딩하여 요청 헤더에 함께 전송합니다.
보안 수준이 낮아 HTTPS와 함께 사용되어야 합니다.
인증 정보가 요청 헤더에 평문으로 노출될 수 있어 보안상 취약합니다.
간단하고 구현이 쉽지만, 안전한 환경에서만 사용해야 합니다.

https://en.wikipedia.org/wiki/Basic_access_authentication

 

2. Bearer 인증: 

(우리가 선택한 인증타입)

클라이언트가 서버로부터 발급받은 액세스 토큰(Access Token)을 사용하여 인증합니다.
액세스 토큰은 주로 OAuth 2.0 프로토콜을 통해 발급됩니다.
토큰은 요청 헤더의 "Authorization" 필드에 "Bearer" 접두사와 함께 전송됩니다.
HTTPS와 함께 사용되는 것이 좋으며, 토큰은 암호화되어 전송되어야 합니다.
서버는 토큰의 유효성을 확인하고, 해당 사용자의 권한을 확인하여 요청을 처리합니다.

 

3. Digest 인증: 

사용자 이름과 비밀번호를 해시 함수를 통해 암호화하여 요청 헤더에 전송합니다.
서버는 비밀번호를 저장하지 않고 해시 값을 저장하므로, 보안 상의 이점이 있습니다.
요청 헤더에 전송되는 정보가 해시 값이므로, Basic 인증보다 보안 수준이 높습니다.
Digest 인증은 nonce(한 번만 사용되는 숫자)와 함께 작동하여 보안을 강화합니다.
하지만 Basic 인증보다 구현이 복잡하고 성능이 낮을 수 있습니다.

https://en.wikipedia.org/wiki/Digest_access_authentication

 

이러한 차이점을 고려하여 적절한 인증 방식을 선택해야 합니다. 보안 요구사항과 편의성, 구현의 난이도 등을 고려하여 적합한 방식을 선택하는 것이 중요합니다.

 

 

 

 

JWT 인증을 수행하는 방식

  1. 클라이언트로부터 요청이 들어오면, 요청의 헤더에서 "Authorization" 필드를 확인합니다.
  2. "Authorization" 필드의 값에는 "Bearer"와 함께 JWT가 포함되어 있습니다. 이 JWT를 추출합니다.
  3. 추출한 JWT를 검증합니다. 검증은 서버에 저장된 비밀 키를 사용하여 수행됩니다. JWT의 유효성, 만료 여부, 서명 등을 확인합니다.
  4. 검증에 성공한 경우, 클라이언트를 인증된 사용자로 간주하여 "Authentication" 객체를 생성합니다. 이 객체에는 사용자의 권한, 로그인 상태 등의 정보가 포함됩니다.
  5. 생성된 "Authentication" 객체를 "SecurityContext"에 저장합니다. "SecurityContext"는 현재 인증된 사용자의 정보를 저장하는 곳입니다.
  6. 이후, 서버의 다른 부분에서는 "SecurityContext"를 통해 현재 인증된 사용자에 대한 정보를 얻을 수 있습니다.

이러한 작업을 수행하기 위해서는 Filter를 사용합니다. Filter는 Servlet에 등록되어 클라이언트의 요청을 가로채고 처리하는 역할을 합니다. Filter는 Servlet에서 제공하는 Filter 인터페이스를 구현해야 합니다.
따라서, JWT를 추출하고 검증하여 인증된 사용자를 표시하고 "SecurityContext"에 저장하는 기능을 가진 Filter를 구현해야 합니다. 이 Filter는 모든 요청을 가로채서 JWT를 처리하고, 인증에 성공한 경우에만 "Authentication" 객체를 생성하고 "SecurityContext"에 저장합니다.
이렇게 구현된 Filter는 클라이언트의 요청이 들어올 때마다 실행되어 JWT를 처리하고 인증 정보를 저장하는 역할을 수행합니다. 이를 통해 JWT를 사용한 인증 기능을 구현할 수 있습니다.

 

 

Filter 인터페이스

Filter 인터페이스는 Java Servlet에서 제공되는 인터페이스로, 

클라이언트의 요청을 가로채고 처리하는 역할을 담당합니다.

Filter 인터페이스를 구현하여 Filter를 만들면, 

해당 Filter는 Servlet에 등록되어 클라이언트의 요청을 가로채고 처리할 수 있습니다. 

Filter는 웹 애플리케이션에서 다양한 작업을 수행하기 위해 사용될 수 있으며, 

보안, 인증, 로깅 등의 기능을 구현하는 데 활용됩니다.

 

public interface Filter {

    default void init(FilterConfig filterConfig) throws ServletException {
    }

    void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
            throws IOException, ServletException;
    }

    default void destroy() {
    }
}

Kotlin으로 변경해 봤다.

interface Filter {

    fun init(filterConfig: FilterConfig) {}
    //Filter를 초기화하는 메서드입니다. 
    //FilterConfig 객체를 매개변수로 받아 필요한 초기화 작업을 수행할 수 있습니다. 
    //이 메서드는 Filter가 처음 생성될 때 한 번 호출됩니다.
    //초기화 하는 이유는?  FilterConfig 객체를 전달받기 위함이며, 필터가 내부적으로 유지해야 하는 상태를 초기화 하기 위해서 이다.

    fun doFilter(request: ServletRequest, response: ServletResponse, chain: FilterChain)
    //클라이언트의 요청을 가로채고 처리하는 메서드입니다. 
    //ServletRequest, ServletResponse 객체와 FilterChain 객체를 매개변수로 받습니다. 
    //이 메서드에서 실제로 Filter의 동작을 구현하며, 요청을 처리하고 다음 필터로 체인을 전달합니다. 
    //만약 체인에 더 이상 필터가 없다면, 요청을 서블릿으로 전달하여 서블릿이 처리하도록 합니다.

    fun destroy() {}
    //Filter를 종료하는 메서드입니다.
    //이 메서드는 Filter가 서버에서 제거될 때 호출되며, 필요한 정리 작업을 수행할 수 있습니다.
}

 

 

이 외 다양한 Filter들

Spring Web 및 Spring Security에서는 기본 Filter 인터페이스를 상속하여 만든 다양한 필터들을 제공한다.

 

https://docs.spring.io/spring-framework/docs/current/javadoc-api/org/springframework/web/filter/package-summary.html

 

아래는 인증 전용 Filter이다.

https://docs.spring.io/spring-security/site/docs/4.2.6.RELEASE/apidocs/org/springframework/security/web/authentication/package-summary.html

 

OncePerRequestFilter

(이렇게 많은 Filter들 중에서 우리가 사용할 Filter인터페이스)

OncePerRequestFilter는 Spring Framework에서 제공하는 추상 클래스로, 

각 요청당 한 번만 실행되는 필터를 구현하기 위해 사용됩니다. 

이를 상속받아 필터를 구현하면, 필터가 각 요청에 대해 한 번만 실행되도록 보장할 수 있습니다. 

이를 통해 중복 실행되는 작업을 효과적으로 제어하고, 필터의 동작을 정확하게 제어할 수 있습니다.

OncePerRequestFilter는 주로 Spring 웹 애플리케이션에서 인증, 권한 부여, 로깅 등의 작업을 수행할 때 사용됩니다.

 

 

 

1. JwtAuthenticationFilter 생성

JwtAuthenticationFilter의 역할은  

1. HTTP 요청을 가로채어 JWT 토큰을 추출합니다.
2. 추출한 JWT 토큰의 유효성을 검증합니다. 
3. 유효한 JWT 토큰인 경우, 해당 토큰에 포함된 사용자 정보를 기반으로 사용자를 인증합니다.

4. 이는 AuthenticationManager를 사용하여 인증을 수행하고, 인증된 사용자를 SecurityContextHolder에 저장합니다.
5. 인증에 성공한 경우, 다음 필터 또는 요청 핸들러로 흐름을 전달합니다.

6. 인증에 실패한 경우, 인증 예외를 발생시키거나 인증 실패 처리를 수행합니다.

@Component
//JWT 토큰의 유효성을 검증한다. //검증에 성공한 경우 해당 토큰의 내용을 사용하여 인증 객체를 생성하고 SecurityContext에 저장하는 역할
class JwtAuthenticationFilter(
    private val jwtPlugin: JwtPlugin
) : OncePerRequestFilter() { //OncePerRequestFilter()필터를 상속받는다.

    companion object {
        private val BEARER_PATTERN = Regex("^Bearer (.+?)$")
    }

    override fun doFilterInternal(
        request: HttpServletRequest,
        response: HttpServletResponse,
        filterChain: FilterChain
    ) {
        val jwt = request.getBearerToken() //추출한 Bearer토큰을 jwt변수에 할당한다.

        //jwt가 null일 수 있기 때문에, null이 아닐 경우에 검증을 한다. (즉, Bearer 토큰이 존재하는 경우에만 JWT 토큰의 유효성을 검증하고 처리)
        if (jwt != null) {
            jwtPlugin.validateToken(jwt)
                //jwtPlugin 객체를 사용하여 JWT 토큰의 유효성을 검증하는 메서드를 호출
                //메서드는 비동기로 처리되며, 검증에 성공한 경우 onSuccess 람다 함수가 호출됩니다.
                .onSuccess {//onSuccess 람다 함수에서는 validateToken() 메서드의 결과로 전달된 it 객체를 사용하여 JWT 토큰의 내용을 추출합니다
                    val userId = it.payload.subject.toLong() //토큰의 subject 값을 가져오고, 이 값을 userId 변수에 Long 타입으로 변환하여 저장합니다.
                    val role = it.payload.get("role", String::class.java) //토큰의 role 값을 가져오고, role 변수에 저장한다.
                    val email = it.payload.get("email", String::class.java) //토큰의 email 값을 가져오고, email 변수에 저장한다.

                    val principal = UserPrincipal( //UserPrincipal 객체를 생성하여 userId, email, role 값을 사용하여 초기화합니다.
                        id = userId,
                        email = email,
                        roles = setOf(role)
                    )
                    // Authentication 구현체 생성
                    val authentication = JwtAuthenticationToken( //JwtAuthenticationToken 객체를 생성
                        principal = principal, //principal에는 이전에 생성한 UserPrincipal 객체를 생성하여 할당함
                        details =  WebAuthenticationDetailsSource().buildDetails(request) // request로 부터 상세정보들을 넣어준다.
                        //Spring Security에서 제공하는 WebAuthenticationDetailsSource 클래스의 buildDetails 메서드를 호출하는 코드이다.
                        //현재 사용자의 인증 요청에 대한 상세 정보를 생성하는 코드로,
                        // HttpServletRequest 객체를 이용하여 사용자의 IP 주소, 세션 ID, 요청한 URI 등의 정보를 추출합니다.
                    )


                    // SecurityContext에 authentication 객체 저장
                    SecurityContextHolder.getContext().authentication = authentication
                }
                //사실 여기에 detail하게 실무로 갈때는 jwt가 실패했을 때, 왜 실패했는지 처리를 해주어야함(만료가 됬다든지 등등), 하지만 여기선 따로 처리하지 않겠음
//                .onFailure {  }
        }

        filterChain.doFilter(request, response) //잊지마!! 다음 필터로 요청을 전달해야됨
    }

    //HttpServletRequest 객체에서 Authorization 헤더에서 Bearer 토큰을 추출하는 함수
    private fun HttpServletRequest.getBearerToken(): String? { //HttpServletRequest로 부터 Token을 가져온다. // 없을 수도 있으니 nullable 함.
        val headerValue = this.getHeader(HttpHeaders.AUTHORIZATION) ?: return null
        //getBearerToken() 함수는 HttpServletRequest 객체의 getHeader() 메서드를 사용하여 Authorization 헤더 값을 가져옵니다.
        //만약 Authorization 헤더 값이 null이라면, 즉 헤더가 존재하지 않는다면 null을 반환합니다.

        return BEARER_PATTERN.find(headerValue)?.groupValues?.get(1)
        //BEARER_PATTERN은 "^Bearer (.+?)$" 정규식을 나타내며, Bearer 다음에 공백을 포함한 그룹을 추출하도록 설정되어 있습니다.
        //BEARER_PATTERN.find(headerValue)를 사용하여 정규식과 일치하는 첫 번째 매칭을 찾고,
        // groupValues를 사용하여 매칭된 그룹의 값을 가져옵니다.
        //여기서 1번 인덱스의 값은 Bearer 다음에 오는 토큰 값입니다.

        //따라서, getBearerToken() 함수는 Bearer 토큰 값을 반환하거나,
        //Authorization 헤더가 없거나 Bearer 토큰이 없을 경우 null을 반환합니다.
        //이를 통해 JWT 토큰을 추출하는 기능을 구현할 수 있습니다.
    }
}

 

 

 

 

 

 

2. JwtAuthenticationToken 생성

JwtAuthenticationToken의 생성 이유

Spring Security에서는 기본적으로 UsernamePasswordAuthenticationToken을 사용하여 사용자의 인증을 처리합니다. 이 토큰은 사용자의 이름과 비밀번호를 기반으로 인증을 수행합니다.

하지만 JWT 인증을 사용하는 경우에는 JWT 토큰에 담긴 정보를 기반으로 인증을 수행해야 합니다. 따라서, JWT 인증을 위해서는 JwtAuthenticationToken과 같이 커스텀한 토큰 클래스를 만들어야 합니다. 이 커스텀 토큰 클래스는 AbstractAuthenticationToken을 상속하고, 필요한 메서드를 구현하여 JWT 토큰을 처리하고 사용자의 인증을 수행합니다.

커스텀 토큰 클래스는 JWT 토큰을 파싱하여 사용자 정보를 추출하고, 필요한 경우 추가적인 인증 작업을 수행할 수도 있습니다. 이를 통해 Spring Security에서 JWT 인증을 처리할 수 있게 됩니다.

따라서, JWT 인증을 사용하는 경우에는 커스텀한 토큰 클래스를 만들어서 JWT 토큰을 처리하고 인증을 수행해야 합니다.

//JWT 기반의 인증을 위한 커스텀 토큰으로서,
//Spring Security에서 사용자의 인증과 권한 검사를 수행하는 데에 활용된다.
class JwtAuthenticationToken(
    private val principal: UserPrincipal, //JWT 토큰에서 추출한 사용자 정보를 담고 있는 UserPrincipal 객체이다.
    details: WebAuthenticationDetails, //요청한 주소 정보와 세션 ID 등의 세부 정보를 담고 있는 WebAuthenticationDetails 객체입니다. 주로 로깅 용도로 사용됩니다.
) : AbstractAuthenticationToken(principal.authorities), Serializable {

    init { //JWT 검증이 됐을시에 바로 생성할 예정이므로, 생성시 authenticated를 true로 설정
        super.setAuthenticated(true) //인증 상태를 true로 설정
        super.setDetails(details) //인증에 필요한 세부 정보를 설정
    }

    override fun getPrincipal() = principal //사용자 정보를 반환하는 메서드입니다. 이 경우, principal 프로퍼티를 반환합니다.

    override fun getCredentials() = null //자격 증명 정보를 반환하는 메서드입니다. 이 경우, JWT 토큰에는 자격 증명 정보가 없으므로 null을 반환합니다.

    override fun isAuthenticated(): Boolean { //인증 상태를 반환하는 메서드입니다. 이 경우, 항상 true를 반환하도록 재정의되어 있습니다.
        return true
    }

}

 

 

 

 

3. UserPrincipal 생성

인증된 사용자의 정보를 담는 클래스로, 주로 인증 후에 사용자의 정보를 저장하고 전달하는 데 사용됩니다.

data class UserPrincipal( //인증된 사용자의 정보를 담는 클래스로, 주로 인증 후에 사용자의 정보를 저장하고 전달하는 데 사용됩니다.
    val id: Long, //사용자의 고유 식별자
    val email: String, //사용자의 이메일 주소
    val authorities: Collection<GrantedAuthority> //사용자의 권한
){
    //보조 생성자로 오버로딩된 생성자를 하나 더 정의함 //보조 생성자는 주 생성자의 일부 기능을 대체하거나 보완하는 역할
    constructor(id: Long, email: String, roles: Set<String>): this(
        id,
        email,
        roles.map {SimpleGrantedAuthority("ROLE_$it")} // "ROLE 후 '언더바'를 넣는게 좋음
    )
}

 

 

 

 

 

 

 

 

 

 

 

 

 

참고 유튜브) https://www.youtube.com/watch?v=XXseiON9CV0

JWT 대충 쓰면 님들 코딩인생 끝남 <- 이게 유튜브 제목이라니...ㅎ

 

 

 

 

블로그의 정보

꿈틀꿈틀 개발일기

jeongminy

활동하기