[Twitter Clone] v0.0.0 Devlog - JWT Authentication (Server Side)

Sierra·2023년 1월 11일
0

Twitter Clone Project

목록 보기
2/4
post-thumbnail

Intro

주의! 현재 코드가 대폭 수정중인 상황이므로 포스팅 내용이 계속해서 변경될 수 있습니다!

웹 서비스에 중요한 요소들이라면 여러가지가 있겠지만, Authentication 의 중요성은 자명하다. 사용자 인증이 없는 서비스라면 어느 누구든 서비스를 무차별적으로 이용할 수 있게 된다.

이번 Devlog의 주제는 Authentication. 이번 프로젝트에서 구현했던 방식을 소개하겠다.

Session VS JWT

어쨌거나 Session ID든 Access Token이든 웹 브라우저 어딘가에 데이터가 저장된다.

즉 보안상의 문제는 큰 문제가 아니었다. WAS 상에서 Session ID 를 관리하는 로드를 줄이자 라고 하기엔 그래봐야 서비스의 규모는 그렇게 크지 않다.
그럼에도 불구하고 Session을 사용하지 않고 JWT를 사용한 이유는 서비스의 확장성을 고려하기 위해서였다. 기껏해야 토이프로젝트 수준인 내 프로젝트가 규모가 커져봤자 한계가 있겠지만 실무에서는 다르니까. 언제든지 서버는 증설될 수 있다는 사실을 생각해야 하므로 JWT 방식을 채택하였다.

인증 절차

인증을 요구하는 API에 요청이 들어온다면 Spring Security Config 클래스 코드에 작성해둔 필터들이 작동한다. JWT인증을 사용하기로 했기 때문에 해당 필터는 직접 개발해야한다.

필터에 전달 된 request를 분석하여 필터 코드 상에서 토큰을 추출해내고 해당 토큰이 유효한 토큰인지 확인한 후, 토큰을 전달한 주체에 대한 정보를 통해 SecurityContextHolder의 authentication context에 해당 정보를 넘긴다. 어떤 사용자가 요청을 하였는지 확인이 되었기에 해당 요청은 유효한 요청으로 간주되고 다음 단계로 넘어간다.

구현 방식

JWT 방식의 문제점은 토큰이 탈취된 순간 끝이라는 것이다. 그래서 Access Token의 만료시간을 짧게 두고 Refresh Token을 통해 토큰을 재발급 해 주는 식으로 소유하고 있는 토큰을 계속 연장해서 사용하는 방식이 보편적으로 쓰인다.

JWT 토큰들의 공통점은 만료시간이 존재한다는 것이다. 즉 만료시간이 끝났을 때 더이상 토큰을 사용할 수 없도록 처리 해 주어야 한다. 그러므로 이번에는 Redis를 이용해 Refresh Token 정보를 저장하기로 하였다.

Access Token은 인메모리에 저장하려다 일단은 Local Storage에 저장하였다. 어쨌든 만료시간이 길지 않으니까. Refresh Token은 HttpOnly, secure 쿠키에 저장하였다. 세션 만큼의 보안 조치를 보장하기 위해서였다.

백엔드는 Spring을 통해 개발했으므로 당연히 Authentication 기능은 Spring Security를 통해 구현하였다. 구현해야 할 것들은 아래와 같았다.

  • JWT Authentication Filter
  • JWT Util class
  • Redis Util class
  • Cookie Util class

코드

코드들을 하나씩 살펴보도록 하겠다. 초기버전이고 우여곡절과 시행착오를 겪고있는 입장이라 코드가 클린하지 못할 수도 있다….

0. 공통

TwitterUser Entity

TwitterUser 엔티티 클래스는 UserDetails 클래스를 상속하였다. 즉 사용자 정보 그자체를 이 엔티티를 통해 처리하겠다는 의미다. 처음에는 굳이 그렇게 까지 상속해가며 할 필요가 있겠거니 싶었지만, 인터페이스의 목적을 생각해보면 답이 나왔다. Spring Security가 유저 정보라는 데이터를 제대로 처리 해 주기 위해 반드시 클래스가 가지고 있어야 할 기능들이 존재해야지만 하기 때문에 가능하면 해당 인터페이스를 상속해서 클래스를 작성 해 주는것이 좋다.

@Entity
@Table(name = "TWITTER_USER")
data class TwitterUser (
    @Id
    @GeneratedValue(generator = "uuid2", strategy = GenerationType.UUID)
    @GenericGenerator(name="uuid2", strategy="uuid2")
    @Column(name = "user_id")
    val userId: UUID = UUID.randomUUID(),

    @Column(name="user_email")
    val email: String,

    @Column(name="user_passwd")
    var passwd: String,

    @Column(name="user_name")
    var userName: String,

    @Column(name="user_role")
    @Enumerated(EnumType.STRING)
    val userRole : Authority = Authority.ROLE_USER,

    @Column(name="provider", columnDefinition = "varchar(10) default 'EMAIL'")
    @Enumerated(EnumType.STRING)
    val provider: Provider = Provider.EMAIL,

    @Column(name="user_status", columnDefinition = "varchar(10) default 'NOR'")
    @Enumerated(EnumType.STRING)
    val userStatus: UserStatus = UserStatus.NOR,

    @Column
    var lastLogin: LocalDateTime? = null,

    ) : BaseEntity(), UserDetails {
	   //Override 된 메소드 생략
}

UserDetailsServiceImpl

크게 특별할 건 없다. TwitterUser Entity가 UserDetails 를 상속했기 때문에 그냥 Repository 단에서 가져오는 것으로 해결이 된다.

@Service
class UserDetailsServiceImpl(private val twitterUserRepository: TwitterUserRepository) : UserDetailsService {
    @Throws(BaseException::class)
    override fun loadUserByUsername(username: String): UserDetails {
        var user : TwitterUser = twitterUserRepository.findUserByEmail(username)
            .orElseThrow{BaseException(BaseResponseCode.USER_NOT_FOUND)}
        return user
    }
}

JwtUtils

Access Token 및 Refresh Token 발급 및 유효성 검사 등 JWT 관련 유틸리티 메소드들이 작성 되어있는 클래스다.

@Component
class JwtUtils(
    private val userDetailsServiceImpl: UserDetailsServiceImpl
) {
    private val logger = LoggerFactory.getLogger(javaClass)

    @Value("\${jwt.security.key}")
    private lateinit var SECRETKEY : String

    companion object{
        const val REFRESH_TOKEN_NAME = "refreshToken"
        const val ACCESS_TOKEN_VALID_TIME =  30 * 60 * 1000L
        const val REFRESH_TOKEN_VALID_TIME =  30 * 24 * 60 * 60 * 1000L
    }
    private val SIGNATUREALG : SignatureAlgorithm = SignatureAlgorithm.HS256

    fun getSigningkey(secretKey : String) : Key{
        return Keys.hmacShaKeyFor(SECRETKEY.toByteArray(StandardCharsets.UTF_8))
    }

    fun extractAllClaims(jwtToken: String) : Claims {
        return Jwts.parserBuilder()
            .setSigningKey(getSigningkey(SECRETKEY))
            .build()
            .parseClaimsJws(jwtToken)
            .body
    }
    fun createToken(userEmail : String, validTime : Long) : String{
        val claims : Claims = Jwts.claims().setSubject(userEmail)
        claims["userEmail"] = userEmail
        return Jwts.builder()
            .setClaims(claims)
            .setIssuedAt(Date(System.currentTimeMillis()))
            .setExpiration(Date(System.currentTimeMillis() + validTime))
            .signWith(getSigningkey(SECRETKEY), SIGNATUREALG)
            .compact()
    }

    fun getAuthentication(token: String): Authentication {
        val userDetails = userDetailsServiceImpl.loadUserByUsername(getUserEmail(token))
        return UsernamePasswordAuthenticationToken(userDetails, "", userDetails.authorities)
    }

    fun getUserEmail(token: String): String {
        val result = extractAllClaims(token).get("userEmail", String::class.java)
        return result
    }

    fun resolveToken(request: HttpServletRequest): String? {
        var token = request.getHeader("Authorization")
        if(token != null) {
            token = token.substring("Bearer ".length)
        }
        return token
    }
    fun validateToken(jwtToken: String): Boolean {
        return try {
            val claims = Jwts.parserBuilder().setSigningKey(getSigningkey(SECRETKEY)).build().parseClaimsJws(jwtToken)
            !claims.body.expiration.before(Date())
        } catch (e: SecurityException) {
            logger.error("Invalid JWT Signature : SecurityException")
            false
        } catch (e : MalformedJwtException){
            logger.error("Invalid JWT Signature : MalformedJwtException")
            false
        } catch (e : UnsupportedJwtException) {
            logger.error("Unsupported JWT Token")
            false
        } catch (e : IllegalArgumentException) {
            logger.error("JWT token is invalid")
            false
        } catch (e : ExpiredJwtException){
            logger.error("JWT token is Expired")
            false
        }
    }

    fun getExpirationPeriod(jwtToken: String) : Int {
        return try {
            val claims = Jwts.parserBuilder().setSigningKey(getSigningkey(SECRETKEY)).build().parseClaimsJws(jwtToken)
            val expireDate = Instant.ofEpochMilli(claims.body.expiration.time).atZone(ZoneId.systemDefault()).toLocalDate()
            val today = LocalDate.now()
            Period.between(today, expireDate).days
        } catch (e : Exception){
            throw BaseException(BaseResponseCode.INVALID_TOKEN)
        }
    }

    fun getExpiration(jwtToken : String) : Long {
        val expiration = Jwts.parserBuilder().setSigningKey(getSigningkey(SECRETKEY)).build()
            .parseClaimsJws(jwtToken).body.expiration
        val now = System.currentTimeMillis()
        return expiration.time - now
    }
}

RedisUtils

Redis 키값 저장 및 삭제 등 Redis 관련 유틸 클래스이다.

@Service
class RedisUtils(
    private val stringRedisTemplate: StringRedisTemplate
) {
    fun getData(key : String) : String? {
        var valueOperations : ValueOperations<String, String> = stringRedisTemplate.opsForValue()
        return valueOperations.get(key)
    }

    fun setDataExpire(key : String, value : String, duration : Long) {
        var valueOperations : ValueOperations<String, String> = stringRedisTemplate.opsForValue()
        val expireDuration : Duration = Duration.ofMillis(duration)
        valueOperations.set(key, value, expireDuration)
    }

    fun deleteData(key : String){
        stringRedisTemplate.delete(key)
    }
}

CookieUtils

쿠키 저장 및 삭제 메소드 등 쿠키 관련 유틸 클래스이다.

@Service
class CookieUtils {
    fun createCookie(cookieName: String, value: String) : ResponseCookie {
        return ResponseCookie.from(cookieName, value)
            .path("/")
            .secure(true)
            .sameSite("None")
            .httpOnly(true)
            .maxAge(JwtUtils.REFRESH_TOKEN_VALID_TIME / 1000)
            .build()
    }

    fun getCookie(req : HttpServletRequest, cookieName : String) : Cookie? {
        val cookies  = req.cookies ?: return null
        for(cookie in cookies){
            if(cookie.name.equals(cookieName)){
                return cookie
            }
        }
        return null
    }

    fun deleteCookie(req: HttpServletRequest, res : HttpServletResponse, cookieName: String) {
        val cookie = getCookie(req, cookieName)
        if(cookie != null){
            val myCookie = Cookie(cookieName, null)
            myCookie.maxAge = 0
            myCookie.path = "/"
            res.addCookie(myCookie)
        }
    }
}

1. 로그인

우선 컨트롤러 단에서 login 메소드가 실행된다. 서비스 단에서 요청이 성공적으로 처리된다면, result 에 저장 된 refreshtoken 데이터를 가지고 쿠키를 생성하고 응답을 전달한다.

@PostMapping("/login")
fun login(@RequestBody request: UserReqDTO.Req.Login,
      req : HttpServletRequest,
      res : HttpServletResponse) : ResponseEntity<BaseResponse<UserResDTO.Res.Login>> {
    val result = twitterUserServiceImpl.login(request)
    val refreshTokenCookie = result.tokenInfo.refreshToken.let { cookieUtils.createCookie(JwtUtils.REFRESH_TOKEN_NAME, it) }
    return ResponseEntity.ok().header(SET_COOKIE, refreshTokenCookie.toString()).body(BaseResponse.success(result))
}

서비스 단에서는 전달받은 유저의 정보를 통해 UsernamePasswordAuthenticationToken을 생성한다. 그 후 해당 토큰을 통해 authenticationManagerBuider를 호출하여 authentication 정보를 생성한다. 해당 정보들을 통해 accessToken, refreshToken을 생성한다. refresh Token 정보는 브라우저 상에 따로 쿠키로 저장하지만 서버사이드 내에서도 해당 정보를 저장해둬야 하기 때문에 Redis에 저장하게 된다.

Redis는 토큰의 만료시간이 지났을 때 자동으로 데이터를 삭제처리 해 줄 수 있다. 키 값의 저장 시간을 따로 정해 줄 수 있기 때문이다.

import org.springframework.security.authentication.UsernamePasswordAuthenticationToken

data class Login(
            val userEmail : String,
            val password : String
){
    fun toAuthentication() : UsernamePasswordAuthenticationToken{
        return UsernamePasswordAuthenticationToken(userEmail, password)
    }
}
override fun login(req: UserReqDTO.Req.Login) : UserResDTO.Res.Login {
        val userInfo = twitterUserRepository.findUserByEmail(req.userEmail)
            .orElseThrow { BaseException(BaseResponseCode.USER_NOT_FOUND) }

        val authenticationToken = req.toAuthentication()
        val authentication = authenticationManagerBuilder.`object`.authenticate(authenticationToken)
        val accessToken = jwtUtils.createToken(authentication.name, JwtUtils.ACCESS_TOKEN_VALID_TIME)
        val refreshToken = jwtUtils.createToken(authentication.name, JwtUtils.REFRESH_TOKEN_VALID_TIME)
        val userInfoDTO = TwitterUserDTO.entityToDTO(userInfo)
        redisUtils.setDataExpire("RT:"+authentication.name, refreshToken, JwtUtils.REFRESH_TOKEN_VALID_TIME)
        return UserResDTO.Res.Login(
            userInfo = userInfoDTO,
            tokenInfo = UserResDTO.Res.TokenInfo(accessToken = accessToken, refreshToken = refreshToken))
    }

2. 로그아웃

아무나 로그아웃 요청을 할 수 없도록 로그아웃 요청에 @AuthenticationPrincipal 어노테이션 설정을 하였다. 즉 로그아웃 요청에선 인증과정이 진행된다. 전달 된 Request에서 RefreshToken 정보가 존재한다면 해당 정보를 삭제시킨다. 또한 전달받은 accessToken 정보를 통해 서비스 단에서 로그아웃 처리를 한다.

@PostMapping("/logout")
fun logout(@AuthenticationPrincipal user : TwitterUser,
       @CookieValue(value = JwtUtils.REFRESH_TOKEN_NAME, defaultValue = "") refreshToken: String,
       req : HttpServletRequest, res : HttpServletResponse) : ResponseEntity<BaseResponse<UserResDTO.Res.Logout>> {

    val accessToken = jwtUtils.resolveToken(req)
    if(accessToken != null){
        val result = twitterUserServiceImpl.logout(accessToken)
        if(refreshToken.isNotEmpty()){
            cookieUtils.deleteCookie(req, res, JwtUtils.REFRESH_TOKEN_NAME)
        }
        return ResponseEntity.ok().body(BaseResponse.success(result))
    } else {
       throw BaseException(BaseResponseCode.INVALID_TOKEN)
    }
}

서비스단에서는 Redis에서 RefreshToken 정보를 삭제시키는 로직, 기존의 AccessToken을 블랙리스트로 만드는 로직이 작동된다. 같은 AccessToken으로 전달되는 요청에 대한 접근을 막기 위해 블랙리스트 등록을 꼭 해주어야 한다.

override fun logout(accessToken: String) : UserResDTO.Res.Logout {
        if(!jwtUtils.validateToken(accessToken)){
            throw BaseException(BaseResponseCode.BAD_REQUEST)
        }

        val authentication = jwtUtils.getAuthentication(accessToken)

        if(redisUtils.getData("RT:"+authentication.name) != null){
            redisUtils.deleteData("RT:"+authentication.name)
        }

        val expiration = jwtUtils.getExpiration(accessToken)
        redisUtils.setDataExpire(accessToken, "logout", expiration)
        return UserResDTO.Res.Logout(authentication.name)
    }

권한이 필요한 요청들은 JwtAuthenticationFilter를 타게 되는데 여기서 블랙리스트 등록 된 토큰에 대한 에러 처리가 진행된다. 로그아웃 이후 탈취 된 토큰을 사용한 요청에 대한 에러처리를 해 줌으로써 만료 시간이 남았지만 로그아웃 처리 된 토큰을 타인이 활용하는 것을 막을 수 있다.

class JwtAuthenticationFilter(
    private val jwtUtils: JwtUtils,
    private val redisUtils: RedisUtils): OncePerRequestFilter() {
    @Throws(IOException::class, ServletException::class, Exception::class, ExpiredJwtException::class)
    override fun doFilterInternal(
        request: HttpServletRequest,
        response: HttpServletResponse,
        filterChain: FilterChain
    ) {
        val path = request.servletPath
        val token: String? = jwtUtils.resolveToken((request))
        when {
            !path.startsWith("/api/auth/v1/reissue") && token != null && jwtUtils.validateToken(token) -> {
                val isLogout = redisUtils.getData(token)
                if (ObjectUtils.isEmpty(isLogout)) {
                    val authentication = jwtUtils.getAuthentication(token)
                    SecurityContextHolder.getContext().authentication = authentication
                }
            }
        }
        filterChain.doFilter(request, response)
    }

}

3. 토큰 재발급

쿠키를 통해 전달 된 RefreshToken을 통해 AccessToken 재발급이 진행된다.

@PostMapping("/reissue")
fun reissue(@CookieValue(value= JwtUtils.REFRESH_TOKEN_NAME, defaultValue = "") refreshToken : String,
    req : HttpServletRequest,
    res : HttpServletResponse) : ResponseEntity<BaseResponse<UserResDTO.Res.TokenInfo>>{

    if(refreshToken.isNotEmpty()){
        val result = twitterUserServiceImpl.reissue(refreshToken)
        if(!result.refreshToken.isEmpty()){
            cookieUtils.deleteCookie(req, res, JwtUtils.REFRESH_TOKEN_NAME)
            val refreshTokenCookie = cookieUtils.createCookie(JwtUtils.REFRESH_TOKEN_NAME, result.refreshToken)
            return ResponseEntity.ok().header(SET_COOKIE, refreshTokenCookie.toString()).body(BaseResponse.success(result))
        }
        return ResponseEntity.ok().body(BaseResponse.success(result))
    } else {
        throw BaseException(BaseResponseCode.REFRESH_TOKEN_EXPIRED)
    }
}

서비스단에서는 해당 RefreshToken이 유효한 지 확인한 후, 유효하다면 해당 RefreshToken이 Redis에 저장되어있고 같은 사용자의 토큰인지 확인한다. 몯느 조건이 맞다면 새로운 AccessToken을 발급하여 반환한다.

override fun reissue(refreshToken : String) : UserResDTO.Res.TokenInfo {
        if(!jwtUtils.validateToken(refreshToken)){
            throw BaseException(BaseResponseCode.REFRESH_TOKEN_EXPIRED)
        }
        val userEmail = jwtUtils.getUserEmail(refreshToken)
        val savedRefreshToken = redisUtils.getData("RT:$userEmail")
        if(ObjectUtils.isEmpty(refreshToken)){
            throw BaseException(BaseResponseCode.BAD_REQUEST)
        }
        if(savedRefreshToken == null || refreshToken != savedRefreshToken){
            throw BaseException(BaseResponseCode.INVALID_TOKEN)
        } else {
            val newAccessToken = jwtUtils.createToken(userEmail, JwtUtils.ACCESS_TOKEN_VALID_TIME)
            val response = UserResDTO.Res.TokenInfo(accessToken = newAccessToken, refreshToken = "")
  
            return response
        }
    }

Outro

지금까지 서버상에서 JWT 토큰 인증 방식을 구현 한 과정을 포스팅 해 보았다. 다음 포스팅은 클라이언트 상에서 어떻게 요청을 처리했는 지에 대해 포스팅 해 보도록 하겠다.

profile
블로그 이전합니다 : https://swj-techblog.vercel.app/

0개의 댓글