[Spring Boot]Redis를 이용한 Refresh Token 구현

한상욱·2024년 7월 9일
0

Spring Boot

목록 보기
15/17
post-thumbnail

들어가며

이 글은 Spring Boot를 공부하며 정리한 글입니다.

AccessToken의 한계

AccessToken에는 사용자의 다양한 정보를 담을 수 있습니다. 때문에 탈취 당할 경우를 우려하여 만료시간을 짧게 생성하여 발급하는 것이 일반적입니다. 이렇게 되면 조금의 문제가 발생할 수 있는데요. AccessToken을 발급받은 사용자가 짧은 만료시간이 지난 이후에 지속적으로 AccessToken을 발급받기 위해 로그인을 해야 한다는 점입니다. 이를 해결하기 위해서 RefreshToken 개념을 도입할 수 있습니다.

RefreshToken

RefreshToken은 만료시간이 다된 사용자에게 새로운 AccessToken을 발급해주기 위한 토큰입니다. 서비스에 인증 인가와는 무관하게 오로지 AccessToken 재 발급을 위해서만 사용합니다. 그렇기에 만료시간도 훨씬 길어집니다. 다만, RefreshToken 역시도 탈취당할 우려가 있으니, 사용자 정보는 넣지 않는 것이 좋습니다.

Redis

Redis는 인메모리 데이터베이스로 빠른 검색 효율을 자랑합니다. 사용자가 로그인하면 RefreshToken을 함께 발급해주면서 이 토큰을 Redis에 저장합니다. 그리고 토큰 재발급을 위한 요청을 보낼때, DB를 조회하여 존재하는 RefreshToken만 유효하다고 판단하여 새로운 토큰을 발급해 줄것입니다.

Spring boot에서 Redis를 이용하기 위해 의존성과 application.yml 파일을 수정해 주겠습니다.

의존성

dependencies {
    // Redis 의존성
	implementation("org.springframework.boot:spring-boot-starter-data-redis")
}

application.yml

spring:
  # Redis
  data:
    redis:
      port: 6379
      host: localhost

Redis Entity

package com.example.msyql_example.member.dto

import org.springframework.data.annotation.Id
import org.springframework.data.redis.core.RedisHash
import org.springframework.data.redis.core.index.Indexed

@RedisHash(value = "refreshToken", timeToLive = 1000 * 60 * 60 * 30L)
class RefreshToken (
    @Id
    @Indexed
    val refreshToken : String,

    @Indexed
    val memberId : Long,
)

Redis 에서는 @RedisHash 어노테이션을 통해서 해쉬 데이터로 저장할 것입니다. value로 키를 지정해주고, ttl 지정을 통해서 유효시간 이후로 자동으로 삭제해주겠습니다. @Id 어노테이션은 JPA를 사용하던것과 다르게 annotation.Id를 이용해주어야 합니다. 속성값으로 검색을 위해 @Indexed어노테이션을 붙여서 refreshToken, memberId 모두 검색 가능하게 해주었습니다.

RefreshTokenRepository

interface RefreshTokenRepository : CrudRepository<RefreshToken, String> {
    fun findByMemberId(id : Long) : RefreshToken?
}

기존의 CrudRepository를 이용해서 간단하게 Repository를 구성할 수 있습니다. 간단하죠. 다만, memberId를 통해서 refreshToken을 검색하는 함수만 추가하겠습니다.

JwtTokenProvider 갱신

이제 사용자가 로그인할 경우 두가지 토큰을 생성하여 전달해야 합니다. 따라서, 메소드를 분리하고 반환타입을 변경해주어서 서비스 로직에서 두가지 토큰을 생성한 후 사용자에게 전달해주는 로직으로 변경하겠습니다.

const val EXPIRATION_MILLISECONDS : Long = 1000 * 60 * 30L
const val REFRESH_EXPIRATION_MILLISECONDS : Long = 1000 * 60 * 60 * 24 * 30L

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

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

    /**
     * 액세스 토큰 생성
     */
    fun createAccessToken(authentication : Authentication) : String {
        val authorities : String = authentication
            .authorities
            .joinToString(",", transform = GrantedAuthority::getAuthority)

        val now = Date()

        val accessExpiration = Date(now.time + EXPIRATION_MILLISECONDS)

        return Jwts
            .builder()
            .subject(authentication.name)
            .claim("auth", authorities)
            .claim("userId", (authentication.principal as CustomUser).id)
            .issuedAt(now)
            .expiration(accessExpiration)
            .signWith(key, Jwts.SIG.HS256)
            .compact()
    }

    /**
     * 리프레시 토큰 생성
     */
    fun createRefreshToken() : String {
        val now = Date()

        val refreshExpiration = Date(now.time + REFRESH_EXPIRATION_MILLISECONDS)

        return Jwts
            .builder()
            .issuedAt(now)
            .expiration(refreshExpiration)
            .signWith(key, Jwts.SIG.HS256)
            .compact()
    }
    ...
}

이제 JWT 토큰 생성을 위한 준비는 모두 마쳤습니다.

MemberService

이제 로그인하는 사용자에게는 두가지의 토큰을 발급해줄 것입니다.

    /**
     * 로그인
     */
    fun login(loginDto: LoginDto) : TokenInfo {
        val authenticationToken = UsernamePasswordAuthenticationToken(loginDto.email, loginDto.password)
        val authentication = authenticationManagerBuilder.`object`.authenticate(authenticationToken)
        val accessToken = jwtTokenProvider.createAccessToken(authentication)
        val refreshToken = jwtTokenProvider.createRefreshToken()
        val data : RefreshToken = RefreshToken(
            refreshToken = refreshToken,
            memberId = (authentication.principal as CustomUser).id
        )
        refreshTokenRepository.save(data)
        return TokenInfo(grantType = "Bearer", accessToken = accessToken, refreshToken = refreshToken)
    }

살짝의 변화가 생겼다면, RefreshToken을 Redis에 저장한다는 것입니다. 이제 로그인한 사용자의 refreshToken은 모두 Redis에 저장됩니다. 이를 이용해서 accessToken 갱신 로직을 작성하겠습니다.

    /**
     * 새로운 토큰 갱신
     */
    fun issueNewAccessToken(refreshToken : String) : String {
        if (!jwtTokenProvider.validateToken(refreshToken)) { throw RuntimeException("유효하지 않은 리프레시 토큰입니다!") }
        val token = refreshTokenRepository.findByIdOrNull(refreshToken)
            ?: throw RuntimeException("잘못된 토큰입니다!")

        val member = memberRepository.findByIdOrNull(token.memberId)
            ?: throw RuntimeException("존재하지 않는 사용자입니다!")

        val authenticationToken = UsernamePasswordAuthenticationToken(member.email, member.password)
        val authentication = authenticationManagerBuilder.`object`.authenticate(authenticationToken)
        return jwtTokenProvider.createAccessToken(authentication)
    }

사용자가 Api요청을 보낼 때, header에 refreshToken을 넣어줄 것입니다. 그 정보를 이용하여 존재하는지 검색한 후, 회원정보를 바탕으로 다시 AccessToken을 발급합니다.

MemberController

    @PostMapping("/refresh")
    private fun issueNewAccessToken(@RequestHeader refreshToken : String) : ResponseEntity<BaseResponse<String>> {
        val result = memberService.issueNewAccessToken(refreshToken)
        return ResponseEntity.status(HttpStatus.OK).body(
            BaseResponse(
                data = result
            )
        )
    }

@RequestHeader를 통해서 header에 데이터를 입력받게 할 수 있습니다. 이를 통해서 결과를 사용자에게 반환합니다.

SecurityConfig

여기서, refresh 메소드는 security에 적용되면 안됩니다. 따라서, Security Config에 예외 메소드를 추가합니다.

@Configuration
@EnableWebSecurity
class SecurityConfig(
    private val jwtTokenProvider: JwtTokenProvider
) {
    @Bean
    fun filterChain(http : HttpSecurity) : SecurityFilterChain {
        http
            .csrf { it.disable() }
            .cors { it.disable() }
            .httpBasic { it.disable() }
            .sessionManagement {
                it.sessionCreationPolicy(
                    SessionCreationPolicy.STATELESS
                )
            }
            .authorizeHttpRequests {
                it.requestMatchers("/api/member/join", "/api/member/login", "/api/member/refresh").anonymous()
                    .requestMatchers("/api/**").hasRole("MEMBER")
                    .anyRequest().permitAll()
            }
            .addFilterBefore(
                JwtAuthenticationFilter(jwtTokenProvider),
                UsernamePasswordAuthenticationFilter::class.java
            )
        return http.build()
    }

이로써, refreshToken 도입이 완료되었습니다.

logout

refreshToken이 도입되면서 로그아웃에 필요성이 생겼습니다. refreshToken은 로그아웃한 사용자에게는 적용되면 안되게 redis에서 삭제해주어야 합니다.

    /**
     * 로그아웃
     */
    fun logout(id : Long) : String {
        val refreshToken = refreshTokenRepository.findByMemberId(id)
            ?: throw RuntimeException("로그인 하지 않은 사용자입니다!")

        refreshTokenRepository.delete(refreshToken)
        return "로그아웃 되었습니다."
    }

이는 사용자의 고유 id를 통해서 수행할 수 있도록 로직을 작성하였습니다.

    @DeleteMapping("/logout")
    private fun logout() : ResponseEntity<BaseResponse<String>> {
        val memberId = (SecurityContextHolder.getContext().authentication.principal as CustomUser).id
        val result = memberService.logout(memberId)
        return ResponseEntity.status(HttpStatus.OK).body(
            BaseResponse(
                data = result
            )
        )
    }

Controller까지 생성하면서 이제 사용자가 로그아웃하는 경우 해당 메소드를 통해서 refreshToken을 삭제해주어야 합니다.

profile
자기주도적, 지속 성장하는 모바일앱 개발자가 되기 위해

0개의 댓글