BackEnd/코틀린 스프링

[Kotlin Spring] 프로젝트 04. JWT를 이용해 로그인 구현하기 01: JWT토큰 생성

세모 2024. 11. 4. 10:39

코틀린 스프링 5번째 시간입니다.

로그인을 통해 JWT를 발행하는 부분 한번 같이 해보겠습니다.


이 기록 겸 강의는 초보자 기준으로 작성이 되었고, 새로 시작하게 된 코틀린 스프링 유저에게 많은 도움이 될 수 있었다면 좋겠다는 마음으로 시작합니다.

틀린 부분이 있다면 알려주시면 감사하겠습니다.


시작하기 앞서 JWT에 대해 설명드려 보겠습니다.

  • JWT(JSON Web Token): 당사자 간에 정보를 안전하게 전송하기 위한 콤팩트하고 독립적인 방법을 정의하는 개방형 표준(RFC 7519)입니다.

토큰의 구조는 점으로 구분된 세부분으로 구성됩니다.

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c

HEADER(알고리즘과 토큰유형) PayLoad(데이터) Signature(서명)
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9 eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c

데이터의 값은 쉽게 열어 볼 수 있기 때문에 PayLoad에는 너무 많은 정보, 유출되어선 안 되는 정보를 넣으면 안 됩니다.

 

쉽게 JWT는 사원증 같은 거라고 보시면 될 것 같아요.

사원증을 가진 사람은 회사에 들어갈 수 있지만 없는 사람은 못 들어가죠, 또 다른 예로 누군가가 사원증을 훔칠 경우 들어갈 수도 있겠죠?

그런 사항에 대해서도 신경을 써주는 게 좋습니다.


그럼 만들기 전에 dependencies를 추가하겠습니다

Maven Repository 에서 추가할 항목은 4가지입니다.

  1. Spring Boot Starter Security
  2. JJWT :: API 
  3. JJWT :: Impl
  4. JJWT :: Jackson
implementation("org.springframework.boot:spring-boot-starter-security")
implementation("io.jsonwebtoken:jjwt-api:0.12.6")
runtimeOnly("io.jsonwebtoken:jjwt-impl:0.12.6")
runtimeOnly("io.jsonwebtoken:jjwt-jackson:0.12.6")

Spring Boot Starter Security는 버전을 지워줍니다.


그리고 application.yml 하단에 secretKey 추가해줍니다.

jwt:
  secret: {32글자 이상}

암호화할 때 사용되는 부분입니다. 아무에게도 알려줘서는 안 됩니다.

또한 유추하기 쉽게 한다면 쉽게 해킹을 당할 수 있으니

추천사항은 해킹하는 사람인척 키보드 아무렇게나 누르는 걸 하면 괜찮지 않을까 생각해 봅니다.

core > authority 패키지를 생성합니다.


토큰의 정보를 담을 Info를 만들어 보겠습니다.

TokenInfo클래스 생성해 줍니다.

data class TokenInfo(
    val grantType: String,
    val accessToken: String,
)

지금은 accessToken 하나지만 추후에 추가해주겠습니다.


JwTokenProvider.kt를 만들어보겠습니다.

여기서는 token을 발행하고 정보를 추출하거나, 유효한 토큰인지 검증합니다.

const val EXPIRATION_MILLISECONDS: Long = 1000 * 60 * 30

@Component
class JwtTokenProvider {
    @Value("\${jwt.access_secret}")
    lateinit var secretKey: String

    private val key by lazy {
        Keys.hmacShaKeyFor(Decoders.BASE64.decode(secretKey))
    }


    /**
     * token 생성
     */
    fun createToken(authentication: Authentication): TokenInfo {
        val authorities: String = authentication
            .authorities
            .joinToString(",", transform = GrantedAuthority::getAuthority)

        val now = Date()
        val accessExpiration = Date(now.time + EXPIRATION_MILLISECONDS)

        // Access Token
        val accessToken = Jwts
            .builder()
            .subject(authentication.name)
            .claim("auth", authorities)
            .issuedAt(now)
            .expiration(accessExpiration)
            .signWith(key, Jwts.SIG.HS256)
            .compact()

        return TokenInfo("Bearer", accessToken)
    }

    /**
     *token 정보 추출
     */
    fun getAuthentication(token: String): Authentication {
        val claims: Claims = getClaims(token)
        val auth = claims["auth"] ?: throw RuntimeException(" 잘못된 토큰 입니다.")
        // 권한 정보 추출
        val authorities: Collection<GrantedAuthority> = (auth as String)
            .split(",")
            .map { SimpleGrantedAuthority(it) }

        val principal: UserDetails = User(claims.subject, "", authorities)

        return UsernamePasswordAuthenticationToken(principal, "", authorities)
    }

    /**
     * Token 검증
     */
    fun validateToken(token: String): Boolean {
        try {
            getClaims(token)
            return true
        } catch (e: Exception) {
            when (e) {
                is SecurityException -> {}  // Invalid JWT Token
                is MalformedJwtException -> {}  // Invalid JWT Token
                is ExpiredJwtException -> {}    // Expired JWT Token
                is UnsupportedJwtException -> {}    // Unsupported JWT Token
                is IllegalArgumentException -> {}   // JWT claims string is empty
                else -> {}  // else
            }
            println(e.message)
        }
        return false
    }

    private fun getClaims(token: String): Claims =
        Jwts.parser()
            .verifyWith(key)
            .build()
            .parseSignedClaims(token)
            .payload
}

 

EXPIRATION_MILLISECONDS를 통해서 만료시간을 지정할 수 있게끔 설정합니다.

현재는 30분으로 설정했지만, 추후에 수정해 주도록겠습니다.

 

토큰 발행 하는 부분을 좀 더 보겠습니다.

val accessToken = Jwts

  • builder() : JWT객체를 생성하기 위한 빌더 초기화입니다.
  • subject(authentication.name) : 토큰의 주체를 설정하는 부분으로 여기서는 이름을 지정하고 있습니다.
  • claim("auth", authorities) : auth라는 키값에 사용자의 권한 정보를 넣습니다.
  • issuedAt(now) :  토큰이 발급된 시간을 뜻하는데 now를 넣어줌으로써 지금으로 설정합니다.
  • expiration(accessExpiration) : 만료 시간을 설정합니다. 위에서 현재 시간 + 설정해 둔 시간이 된 값이 들어왔습니다. 현재기준 30분 뒤 만료됨을 알 수 있습니다.
  • signWith(key, Jwts.SIG.HS256) : key를 사용하여 HS256 알고리즘으로 서명한다는 내용입니다.
  • compact() : 모든 설정을 바탕으로 JWT문자열을 생성합니다.

JWT 인증 필터 구현 하겠습니다.

JwtAuthenticationFilter 클래스를 생성해 줍니다.

class JwtAuthenticationFilter(
    private val jwtTokenProvider: JwtTokenProvider
) : GenericFilterBean() {
    override fun doFilter(request: ServletRequest?, response: ServletResponse?, chain: FilterChain?){
        val token = resolveToken(request as HttpServletRequest)
        if (token != null && jwtTokenProvider.validateToken(token)) {
        val authentication = jwtTokenProvider.getAuthentication(token)
        SecurityContextHolder.getContext().authentication = authentication
        }
        chain?.doFilter(request, response)
    }

    private fun resolveToken(request: HttpServletRequest): String? {
        val bearerToken = request.getHeader("Authorization")
        return if (StringUtils.hasText(bearerToken) &&
            bearerToken.startsWith("Bearer")) {
            bearerToken.substring(7)
        } else {
            null
        }
    }
}
  • JwtAuthenticationFilter클래스는 GenericFilterBean을 상속받아서 필터 기능을 구현합니다.
  • 생성자에서 JwtTokenProvider를 인스턴스 받아서 JWT토큰의 유효성 검사와 인증 정보를 가져옵니다.
  • doFilter 메서드는 필터의 핵심으로, 요청이 들어올 때마다 호출됩니다.
  • resolveToken 메서드를 호출하여 요청 헤더에서 JWT토큰을 추출합니다.
  • 토큰이 유효하면 JwtTokenProvider를 사용하여 인증 정보를 가져오고, SecurityContextHolder에 설정합니다.
  • 다음 필터로 요청을 전달하기 위해 chain.doFilter(request, response)를 호출합니다
  • Authorization헤더에서 "Bearer"로 시작하는 문자열을 찾고, 토큰 부분만 반환합니다.
  • 유효한 형식이 아닐 경우 null을 반환합니다.

여기까지 진행했을 때, 서버를 가동하고 서버에 접속을 하려고 하면 접속이 안될 겁니다.

비밀번호 입력하라는 내용이 나오게 될 것인데, API를 제작하고 접속하려면 제한이 없어야 하기에 그 부분을 수정해 주겠습니다.

SecurityConfog를 만들어주겠습니다.

@Configuration
@EnableWebSecurity
class SecurityConfig(
    private val jwtTokenProvider: JwtTokenProvider
) {
    @Bean
    fun filterChain(http: HttpSecurity): SecurityFilterChain {
        http
            .httpBasic { it.disable() }
            .csrf { it.disable() }
            .sessionManagement {
                it.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
            }
            .authorizeHttpRequests {
                it.requestMatchers("/","/member/signup").anonymous()
                    .anyRequest().permitAll()
            }
            .addFilterBefore(
                JwtAuthenticationFilter(jwtTokenProvider),
                UsernamePasswordAuthenticationFilter::class.java
            )

        return http.build()
    }

    @Bean
    fun passwordEncoder(): PasswordEncoder =
        PasswordEncoderFactories.createDelegatingPasswordEncoder()
}
  • @Configuration: 설정 클래스로 사용됨을 나타냅니다.
  • @EnableWebSecurity: Spring Security기능을 활성화합니다.
  • Security클래스는 JwtTokenProvider를 주입받아 보안 설정을 구성합니다.
  • filterChain 메서드 
    • authorizeHttpRequests: 요청에 대한 권한 부여 규칙을 설정합니다. "/member/signup"이란 endPoint에는 익명 사용자(로그인하지 않은 사용자)가 접근할 수 있도록 설정합니다. 추후에 endpoint가 많아질 경우 처리 방법에 대해서도 이야기해볼 생각입니다.
    • 추가로 하단에 표를 작성하여 권한 부여에 관한 내용을 같이 보여드리겠습니다.
permitAll() 권한의 여부에 관계없이 모두 접근 가능
denyAll() 권한의 여부에 관계없이 모두 접근 불가능
anonymous() 인증을 되지 않은 사용자일 경우에 접근 가능
rememberMe() Remember-me기능으로 로그인한 사용자일 경우
authenticated() 인증을 한 사용자인 경우
fullyAuthenticated() 인증되지 않은 사용자가 아니고, Remember-me기능으로 로그인하지 않은 사용자 일 경우
hasRole("auth1") auth1이라는 권한을 가지고 있는 경우(auth1은 원하는 권한으로 변경 가능)
hasAnyRole("auth1", "auth2", "auth3") 권한들 중 하나라도 가지고 있는 경우 (갯수 제한은 없습니다.)

 

JWT를 만들어봤고 다음은 회원가입시 권한부여 및 로그인구현까지 진행해 보겠습니다.

부족한 글 읽어 주셔서 감사합니다.

피드백은 언제든 환영입니다.