본문 바로가기
Spring Framework/스프링

(2) JWT Access, Refresh 토큰 + Redis : 스프링 코드 구현 [Kotlin]

by 스코리아 2023. 12. 30.

안녕하세요, 스코리아입니다.

오늘은 저번 시간에 이어서 스프링에서 JWT(Access, Refresh 토큰)을 Redis와 함께 사용하는 코드를 구현해 보겠습니다. 언어는 코틀린(Kotlin)으로 진행하겠습니다.

 

JWT

이전 포스팅에서 JWT가 무엇이고 Refresh 토큰을 사용해야 하는 이유, Refresh 토큰의 보안문제와 해결방안, Redis 활용 장점에 대해서 설명드렸으니, 꼭 읽어보시기 바랍니다!

[이전 내용] (1) JWT Access, Refresh 토큰 + Redis : 소개 및 보안 : 바로가기

 

 

프로젝트 구현 흐름

스프링으로 코드를 구현하기 전에 어떤 식으로 구현해 볼지 Flow에 대해서 먼저 설명해 드리겠습니다.

Access 토큰의 유효기간은 30분, Refresh 토큰의 유효기간은 1달(30일)로 설정하여 진행하겠습니다.

 

JWT Access + Refresh Token Flow

 

로그인 API (/api/member/login):

- Refresh 토큰을 생성한 뒤 Refresh 토큰을 Redis에 저장해 둡니다.

 

Refresh 토큰을 이용하여 Access 토큰을 재발급하는 API (/api/member/token/refresh):

- Refresh 토큰이 유효한지 Redis에 확인하는 과정을 거칩니다. Refresh 토큰이 유효하다면 새로운 Access 토큰새로운 Refresh 토큰을 발급하며 유효하지 않다면 exception을 터뜨립니다.

- 기존 Refresh 토큰은 Redis에서 제거하며 새로운 Refresh 토큰은 Redis에 저장합니다. 여기서 기존 Refresh 토큰을 제거하는 이유는 저번 포스팅에서 설명드렸다시피, Refresh 토큰은 1회용이 되어야 보안상 안전하기 때문입니다.

 

모든 기기에서 강제 로그아웃 API (/api/member/token/logout):

- 사실상 이 기능을 사용하기 위해 Refresh 토큰을 Redis에 저장하고 비교하는 과정을 거칩니다.

- 로그인된 유저 아이디와 일치하는 Refresh 토큰을 모두 Redis에서 삭제하는 API입니다.

- Refresh 토큰을 모두 제거한다고 해서 모든 기기에서 곧바로 로그아웃이 되는 것은 아닙니다. 어떤 기기에서 Access 토큰의 유효기간이 남아있다면 그동안은 로그인 상태가 유지됩니다. 다만 Access 토큰은 유효기간이 30분으로 짧기 때문에 크게 걱정될 부분은 아닙니다.

- Refresh 토큰을 모두 제거함으로써 보안성이 크게 향상됩니다.

 

Refresh 토큰만 Redis에 저장하고 Access 토큰은 Redis에 저장하지 않는 이유가 무엇일까요?

JWT의 가장 큰 특징이 '무상태성'이기 때문입니다. 서버에 별도의 저장소가 없어야 하고 완전한 '무상태'를 가져야 합니다. 그런데 Access 토큰을 Redis에 저장하게 되면 무상태성이 깨지고, 오히려 이럴 거면 세션을 이용하는 게 더 좋은 선택일지 모릅니다.

 

 

프로젝트 구성

Spring + Kotlin

스프링 3.1.0 버전을 사용하였으며, 언어는 Kotlin입니다. Spring Security는 5.0 이상에서 진행해 주세요.

또한 아래 포스팅 내용을 참고하여 Redis 설치, dependency 추가, RedisConfig 추가를 진행해 주시기 바랍니다. (1~4번 과정)

[이전 내용] 스프링에서 Redis 캐시 사용하기 : 바로가기

 

 

코드 구현

1. build.gradle 에 dependency 추가

dependencies {
	implementation("org.springframework.boot:spring-boot-starter-security")
    
	implementation("io.jsonwebtoken:jjwt-api:0.11.5")
	runtimeOnly("io.jsonwebtoken:jjwt-impl:0.11.5")
	runtimeOnly("io.jsonwebtoken:jjwt-jackson:0.11.5")
}

 

스프링 시큐리티와 JWT를 사용하기 위해 dependency를 추가합니다.

 

 

2. application.yml 에 JWT Secret 추가

jwt:
  access_secret: DadFufN4Oui8Bfv3ScFj6R9fyJ9hD45E6AGFsXgFsRhT4YSdSb
  refresh_secret: 2X2N7NQvCJyvQUSK3nnvsuRmVC7NA4XaydaWcYsmGFzTcrYmMN

 

Access, Refresh 토큰의 Secret Key를 설정해 주세요.

HS256 알고리즘을 사용할 것이기 때문에 256 비트보다 커야 하므로 (한 단어당 8비트) 32~64자의 영어와 숫자가 섞인 랜덤 문자열로 설정해 주세요.

 

 

3. TokenInfo

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

 

Controller에서 response 할 객체인 TokenInfo입니다.

- userId에는 사용자의 아이디가 들어갑니다.

- grantType에는 JWT 인증 타입인 'Bearer'가 들어갑니다. HTTP Header Authorization의 prefix에 grantType(Bearer)가 붙여집니다.

 

 

4. CustomUser

class CustomUser(
    userId: String,
    password: String,
    authorities: Collection<GrantedAuthority>
) : User(userId, password, authorities)

 

Spring Security의 User(UserDetails) 객체를 상속하여 CustomUser 객체를 만듭니다.

CustomUser로 Token에 userId를 관리합니다.

 

 

5. JwtTokenProvider

const val ACCESS_EXPIRATION_MILLISECONDS: Long = 1000 * 60 * 30 // 1시간
const val REFRESH_EXPIRATION_MILLISECONDS: Long = 1000 * 60 * 60 * 24 * 30 // 30일

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

    @Value("\${jwt.refresh_secret}")
    lateinit var refreshSecretKey: String

    private val accessKey by lazy { Keys.hmacShaKeyFor(Decoders.BASE64.decode(accessSecretKey)) }
    private val refreshKey by lazy { Keys.hmacShaKeyFor(Decoders.BASE64.decode(refreshSecretKey)) }

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

        val now = Date()
        val accessExpiration = Date(now.time + ACCESS_EXPIRATION_MILLISECONDS)
        val refreshExpiration = Date(now.time + REFRESH_EXPIRATION_MILLISECONDS)

        // Access Token
        val accessToken = Jwts
            .builder()
            .setSubject(authentication.name) // 토큰 제목
            .claim("auth", authorities) // 권한
            .setIssuedAt(now) // 토큰 발급시간
            .setExpiration(accessExpiration) // 토큰 만료시간
            .signWith(accessKey, SignatureAlgorithm.HS256) // 키, 알고리즘
            .compact()

        // Refresh Token
        val refreshToken = Jwts
            .builder()
            .setSubject(authentication.name)
            .claim("auth", authorities)
            .setIssuedAt(now)
            .setExpiration(refreshExpiration)
            .signWith(refreshKey, SignatureAlgorithm.HS256)
            .compact()

        return TokenInfo(authentication.name, "Bearer", accessToken, refreshToken)
    }

    /**
     * Token 정보 추출
     */
    fun getAuthentication(token: String): Authentication {
        val claims: Claims = getAccessTokenClaims(token)
        val auth = claims["auth"] ?: throw RuntimeException("잘못된 토큰입니다.")

        // 권한 정보 추출
        val authorities: Collection<GrantedAuthority> = (auth as String)
            .split(",")
            .map { SimpleGrantedAuthority(it) }

        val principal = CustomUser(claims.subject, "", authorities)
        return UsernamePasswordAuthenticationToken(principal, "", authorities)
    }

    fun validateRefreshTokenAndCreateToken(refreshToken: String): TokenInfo {
        try {
            val refreshClaims: Claims = getRefreshTokenClaims(refreshToken)
            val now = Date()
            
            // 새로운 access 토큰 발급
            val newAccessToken: String = Jwts
                .builder()
                .setSubject(refreshClaims.subject)
                .claim("auth", refreshClaims["auth"])
                .setIssuedAt(now)
                .setExpiration(Date(now.time + ACCESS_EXPIRATION_MILLISECONDS))
                .signWith(accessKey, SignatureAlgorithm.HS256)
                .compact()

            // 새로운 refresh 토큰 발급
            val newRefreshToken: String = Jwts
                .builder()
                .setSubject(refreshClaims.subject)
                .claim("auth", refreshClaims["auth"])
                .setIssuedAt(now)
                .setExpiration(Date(now.time + REFRESH_EXPIRATION_MILLISECONDS))
                .signWith(refreshKey, SignatureAlgorithm.HS256)
                .compact()

            return TokenInfo(refreshClaims.subject, "Bearer", newAccessToken, newRefreshToken)
        } catch (e: Exception) {
            throw e
        }
    }

    fun validateAccessTokenForFilter(token: String): Boolean {
        try {
            getAccessTokenClaims(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
            }
            throw e
        }
    }

    private fun getAccessTokenClaims(token: String): Claims =
        Jwts.parserBuilder()
            .setSigningKey(accessKey)
            .build()
            .parseClaimsJws(token)
            .body

    private fun getRefreshTokenClaims(token: String): Claims =
        Jwts.parserBuilder()
            .setSigningKey(refreshKey)
            .build()
            .parseClaimsJws(token)
            .body
}

 

- Access 토큰 만료기간은 30분, Refresh 토큰의 만료기간은 30일로 설정하였습니다. 테스트를 위해 이 값을 줄이셔도 됩니다.

- JwtTokenProvider는 Access, Refresh 토큰을 생성하고 검증하며 토큰을 재발급해주는 역할을 수행합니다. 

- getAccessTokenClaims와 getRefreshTokenClaims 함수는 각각 Access 토큰과 Refresh 토큰이 유효한 토큰인지 검사를 진행합니다.

 

 

6. JwtAuthenticationFilter

class JwtAuthenticationFilter(
    private val jwtTokenProvider: JwtTokenProvider
) : GenericFilterBean() {

    override fun doFilter(request: ServletRequest?, response: ServletResponse?, chain: FilterChain?) {
        try {
            val token = resolveToken(request as HttpServletRequest)

            if (jwtTokenProvider.validateAccessTokenForFilter(token)) {
                val authentication = jwtTokenProvider.getAuthentication(token)
                SecurityContextHolder.getContext().authentication = authentication
            }
        } catch (e: Exception) {
            request?.setAttribute("exception", e)
        }

        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 {
            throw ApiCustomException(HttpStatus.UNAUTHORIZED.value(), "인증이 필요한 서비스입니다.")
        }
    }
}

 

JWT 인증을 위해 UsernamePasswordAuthenticationFilter 이전에 실행되는 Custom Filter입니다.

따라서 JwtAuthenticationFilter를 통과하게 되면 UsernamePasswordAuthenticationFilter는 자동으로 통과하게 됩니다.

 

resolveToken 함수는 HTTP header에서 Authorization 부분을 가져오고, prefix인 Bearer을 제거한 JWT Access 토큰만을 추출하여 return 합니다.

 

그리고 doFilter에서 가져온 token이 유효한지 jwtTokenProvider를 이용해 검증한 뒤, Authentication 객체를 생성하여 가져옵니다. 또한 이것을 Security Context에 저장하게 됩니다. 이렇게 되면 토큰 검증을 한 뒤에 데이터베이스를 거치지 않고 Seuciry Context에 저장된 authentication의 유저 아이디로 해당 유저가 유효한 유저인지 검증하게 됩니다. 만약 유저의 다른 정보를 얻기 위해서는 별도로 데이터베이스를 조회해야 합니다.

 

 

7. SecurityConfig

@Configuration
@EnableWebSecurity
class SecurityConfig(
    private val jwtTokenProvider: JwtTokenProvider,
    private val entryPoint: AuthenticationEntryPoint,
) {
    @Bean
    fun filterChain(http: HttpSecurity): SecurityFilterChain {
        return http
            .httpBasic { it.disable() }
            .csrf { it.disable() }
            .sessionManagement { it.sessionCreationPolicy(SessionCreationPolicy.STATELESS) }
            .authorizeHttpRequests {
                it.requestMatchers("/api/member/signup", "/api/member/login", "/api/member/token/refresh").anonymous()
                    .requestMatchers("/api/member/**").hasRole("MEMBER")
                    .anyRequest().permitAll()
            }
            .addFilterBefore(
                JwtAuthenticationFilter(jwtTokenProvider), // 먼저 실행 (앞에 있는 필터가 통과하면 뒤에 있는 필터는 검사하지 않음)
                UsernamePasswordAuthenticationFilter::class.java
            )
            .exceptionHandling { it.authenticationEntryPoint(entryPoint) }
            .build()
    }

    @Bean
    fun passwordEncoder(): PasswordEncoder =
        PasswordEncoderFactories.createDelegatingPasswordEncoder()
}

 

- RestAPI와 JWT 인증방식을 사용할 것이므로 httpBasic disable, csrf disable , session stateless (세션을 사용하지 않음)을 설정해 줍니다.

- 회원가입, 로그인, 토큰 재발급 API는 로그인하지 않은 사용자도 접근 가능하게끔 anonymous (익명)로 설정하며,  '/api/member/'로 시작하는 모든 경로는 MEMBER 역할이 있는 사용자만 접근 가능하게 합니다. (이외의 경로는 모두 로그인이 되어야 접근이 가능합니다)

- 우리가 직접 구현한 JwtAuthenticationFilter가 UsernamePasswordAuthenticationFilter보다 먼저 실행되게끔 합니다.

- Spring Security 5 이상부터는 createDelegatingPasswordEncoder을 이용하여 passwordEncoder를 생성하라고 권장하고 있습니다.

 

 

8. CustomUserDetailsService

@Service
class CustomUserDetailsService(
    private val memberRepository: MemberRepository
) : UserDetailsService {
    override fun loadUserByUsername(username: String): UserDetails =
        memberRepository.findByUserId(username)
            ?.let { return createUserDetails(it) } ?: throw UsernameNotFoundException("해당 유저는 없습니다.")

    private fun createUserDetails(member: Member): UserDetails =
        CustomUser(
            member.userId,
            member.password,
            member.memberRole!!.map { SimpleGrantedAuthority("ROLE_${it.role}") }
        )
}

 

인증 API 호출 시에만 loadUserByUsername을 호출하여 DB에서 유저의 아이디, 비밀번호, 권한 정보를 가져온 뒤 CustomUser로 변환하여 return 합니다.

만약 유저 정보를 찾을 수 없다면, UsernameNotFoundException을 터뜨립니다.

 

 

9. RefreshTokenInfoRepositoryRedis

@Repository
class RefreshTokenInfoRepositoryRedis(
    private val redisTemplate: RedisTemplate<String, String>
) {
    companion object {
        private const val KEY_PREFIX = "refreshToken" // refreshToken:{userId}:{refreshToken}
    }

    fun save(userId: String, refreshToken: String) {
        val key = "$KEY_PREFIX:$userId:$refreshToken"
        redisTemplate.opsForValue().set(key, "", REFRESH_EXPIRATION_MILLISECONDS, TimeUnit.MILLISECONDS)
    }

    fun findByRefreshToken(refreshToken: String): String? {
        val key = redisTemplate.keys("$KEY_PREFIX:*:$refreshToken").firstOrNull()
        return key?.let { redisTemplate.opsForValue().get(it) }
    }

    fun deleteByRefreshToken(refreshToken: String) {
        val keys = redisTemplate.keys("$KEY_PREFIX:*:$refreshToken")
        redisTemplate.delete(keys)
    }

    fun deleteByUserId(userId: String) {
        val keys = redisTemplate.keys("$KEY_PREFIX:$userId:*")
        redisTemplate.delete(keys)
    }
}

 

userId와 refreshToken을 키값으로 하여금 저장, 검색, 삭제할 수 있는 Redis Repository입니다.

- 간단한 구조이기 때문에 순수 redisTemplate을 사용하였습니다. 추후에 Redis Entitiy와 CrudRepository를 사용하여 개선해도 되겠습니다.

- key 값은 refreshToken:{userId}:{refreshToken} 형태로 저장되며, value는 빈값입니다. 추후에 value는 user-agent, ip주소와 같은 유저의 기기, 네트워크 정보를 가지는 객체를 넣는 것도 보안상 좋아 보입니다.

- deleteByUserId는 서비스 계층에서 모든 Refresh 토큰을 삭제하는 기능을 만들 때 사용됩니다.

 

 

10. MemberService

@Transactional
@Service
class MemberService(
    private val memberRepository: MemberRepository,
    private val memberRoleRepository: MemberRoleRepository,
    private val authenticationManagerBuilder: AuthenticationManagerBuilder,
    private val jwtTokenProvider: JwtTokenProvider,
    private val passwordEncoder: PasswordEncoder,
    private val refreshTokenInfoRepositoryRedis: RefreshTokenInfoRepositoryRedis
) {
    /**
     * 회원가입
     */
    fun signUp(memberDtoRequest: MemberDtoRequest): String {
        // ID 중복 검사
        var member: Member? = memberRepository.findByUserId(memberDtoRequest.userId)
        if (member != null) {
            throw InvalidInputException("loginId", "이미 등록된 ID 입니다.")
        }

        member = memberDtoRequest.toEntity()
        member.password = passwordEncoder.encode(member.password)

        memberRepository.save(member)
        memberRoleRepository.save(MemberRole(null, ROLE.MEMBER, member))

        return "회원가입이 완료되었습니다."
    }

    /**
     * 로그인 -> 토큰 발행
     */
    fun login(loginDto: LoginDto): TokenInfo {
        val authenticationToken = UsernamePasswordAuthenticationToken(loginDto.userId, loginDto.password)
        val authentication = authenticationManagerBuilder.`object`.authenticate(authenticationToken)
        val createToken: TokenInfo = jwtTokenProvider.createToken(authentication)

        refreshTokenInfoRepositoryRedis.save(loginDto.userId, createToken.refreshToken)
        return createToken
    }
    
    /**
     * 유저의 모든 Refresh 토큰 삭제
     */
    fun deleteAllRefreshToken(userId: String) {
        refreshTokenInfoRepositoryRedis.deleteByUserId(userId)
    }
    
    /**
     * Refresh 토큰 검증 후 토큰 재발급
     */
    fun validateRefreshTokenAndCreateToken(refreshToken: String): TokenInfo{
        // Redis에 refreshToken 유효 여부 확인
        refreshTokenInfoRepositoryRedis.findByRefreshToken(refreshToken)
            ?: throw InvalidInputException("refreshToken", "만료되거나 찾을 수 없는 Refresh 토큰입니다. 재로그인이 필요합니다.")

        // 새로운 accessToken, refreshToken 발급
        val newTokenInfo: TokenInfo = jwtTokenProvider.validateRefreshTokenAndCreateToken(refreshToken)

        // 기존 refreshToken Redis에서 제거 : refreshToken은 1회용
        refreshTokenInfoRepositoryRedis.deleteByRefreshToken(refreshToken)
        
        // 새로운 refreshToken Redis에 추가
        refreshTokenInfoRepositoryRedis.save(newTokenInfo.userId, newTokenInfo.refreshToken)

        return newTokenInfo
    }
}

 

회원 서비스 계층입니다.

위에서 설명드린 '프로젝트 구현 흐름'의 내용과 동일하므로 자세한 설명은 생략하겠습니다. 주석의 내용을 참고해 주세요.

 

 

11. MemberController

@RequestMapping("/api/member")
@RestController
class MemberController(
    private val memberService: MemberService
) {
    /**
     * 회원가입
     */
    @PostMapping("/signup")
    fun signUp(@RequestBody @Valid memberDtoRequest: MemberDtoRequest): BaseResponse<Unit> {
        val resultMsg: String = memberService.signUp(memberDtoRequest)
        return BaseResponse(statusMessage = resultMsg)
    }

    /**
     * 로그인
     */
    @PostMapping("/login")
    fun login(@RequestBody @Valid loginDto: LoginDto): BaseResponse<TokenInfo> {
        val tokenInfo: TokenInfo = memberService.login(loginDto)
        return BaseResponse(data = tokenInfo)
    }

    /**
     * Refresh 토큰
     */
    @PostMapping("/token/refresh")
    fun tokenRefresh(@RequestBody @Valid tokenRefreshDto: TokenRefreshDto): BaseResponse<TokenInfo> {
        val tokenInfo: TokenInfo = memberService.validateRefreshTokenAndCreateToken(tokenRefreshDto.refreshToken)
        return BaseResponse(data = tokenInfo)
    }

    /**
     * 로그아웃
     */
    @GetMapping("/token/logout")
    fun tokenLogout(): BaseResponse<Unit> {
        val userId = (SecurityContextHolder.getContext().authentication.principal as CustomUser).username
        memberService.deleteAllRefreshToken(userId)
        return BaseResponse()
    }
}

 


API 테스트

1. 로그인 API

로그인 API 호출

성공적으로 Access 토큰과 Refresh 토큰이 발급된 모습입니다.

 

Redis 서버 : 키 확인

Redis 서버에서 확인해 보니, 값이 잘 들어간 것을 확인할 수 있습니다. (API를 총 3번 호출해 보았습니다)

 

 

2. 토큰 재발급 API

'로그인 API'에서 발급한 Refresh 토큰을 이용하여 '토큰 재발급 API'를 호출해 보겠습니다.

토큰 재발급 API 호출 - 1

성공적으로 Access 토큰과 Refresh 토큰이 재발급되었습니다.

이번에는 위와 똑같은 상태로 재호출 해보겠습니다.

 

토큰 재발급 API 호출 - 2

Refresh 토큰이 유효하지 않다고 뜨는 걸 볼 수 있습니다. 이로써 기존 Refresh 토큰이 Redis에서 제거되었고, Refresh 토큰은 1회용으로만 사용되었음을 알 수 있습니다.

 

 

3. 모든 기기에서 로그아웃 API

로그인된 유저의 모든 Refresh 토큰을 제거하는 API입니다.

모든 기기에서 로그아웃 API 호출

API가 성공적으로 호출되었습니다.

 

Redis 서버 : 모든 키 삭제 확인

Redis 서버를 확인해 보니, test1 유저아이디를 가진 모든 Refresh 토큰이 삭제되었음을 확인할 수 있었습니다.

 

 


지금까지 스프링에서 JWT(Access, Refresh 토큰)을 Redis와 함께 사용하는 코드를 구현해 보았습니다.

다음 시간에는 Refresh 토큰의 보안을 향상하기 위해 'Refresh 토큰 만료 기간의 3분의 2가 지났을 때만 유효기간을 연장시키는 방법', '공용 PC 기능 만들기' 등의 내용으로 찾아뵙겠습니다.

 

 

모든 소스코드는 제 Github Repository에서 확인하실 수 있습니다.

https://github.com/skorea6/jwt-spring-security-project

 

GitHub - skorea6/jwt-spring-security-project: 스프링 시큐리티에서 JWT 사용해보기 with Kotlin

스프링 시큐리티에서 JWT 사용해보기 with Kotlin. Contribute to skorea6/jwt-spring-security-project development by creating an account on GitHub.

github.com

 

읽어주셔서, 감사합니다.