티스토리 뷰

반응형

세팅글을 쓴다는게 너무 오랫동안 방치되다보니 과거 코드보단 그래도 나름 최신 코드가 예제로 쓰기 좋은거 같아 코틀린 최신 코드로 되어있다는 점 양해부탁드립니다.

여기서 잠깐

이 글을 읽기 전 사전 지식이 필요합니다.

전자 서명: 전자 서명은 디지털 형식으로 문서나 데이터의 서명 및 인증을 수행하는 방법입니다. 전자 서명은 종이 서명의 디지털 대응물로, 문서의 진위성, 무결성, 서명자의 신원을 확인하는 데 사용됩니다.

주요 특징은 다음과 같습니다.

1. 인증: 서명자가 누구인지 확인할 수 있습니다.
2. 무결성: 서명된 문서가 변경되지 않았음을 보장합니다.

디지털 서명의 동작 원리
디지털 서명은 공공 키 기반 구조(PKI, Public Key Infrastructure)를 사용합니다.
키 쌍 생성: 서명자는 공개 키와 비밀 키 쌍을 생성합니다.해시 생성: 서명할 데이터에 대해 해시 함수(예: SHA-256)를 사용하여 고정 길이의 해시 값을 생성합니다.

서명 생성: 비밀 키로 해시 값을 암호화하여 디지털 서명을 생성합니다.서명 검증: 수신자는 서명자의 공개 키를 사용하여 서명을 복호화하고, 데이터에 대해 동일한 해시 함수를 적용하여 얻은 해시 값과 비교하여 서명을 검증합니다.

이걸 이해하셔야지만 전자서명으로인해 출처를 믿고 사용할 수 있다라는 것을 이해할 수 있습니다.

 

그럼 JWT구현에 들어가기전 JWT가 무엇인지 알아볼까요?

JWT란?

JWT는 Json Web Token의 앞 철자만 따온 단어로 Server와 Client간 출처를 명확하게 하여 서로 신뢰를 갖고 사용하기 위해 사용되는 web token입니다. 주로 인증이나 인가에 많이 사용되며 정보가 적은 경우 저장소 없이 token에만 저장하여 stateless한 protocol 환경에서 유리합니다.

stateless한 protocol에서 유리한 이유는 다음과 같습니다.

  • 서버와 저장소로부터 인증 정보를 지속적으로 받아와야하는 session과 달리 jwt 그 자체에 인증정보를 담고 있기 때문에 저장소 없이 jwt 내부 정보 열람이 가능합니다.
  • 전자서명하기위한 인증키를 서버에 저장하고 외부로 노출 없이 관리한다면 외부에서 내부 정보 열람이나 토큰 변경 없었다는것을 보증할 수 있어 신뢰를 갖고 토큰 내부 정보에 있는 데이터로 인증 인가가 가능합니다.
  • 저장소 없이 인증 인가가 가능하기 때문에 scale-out 시 database나 redis 같은 저장소 고민을 할 필요가 없습니다.

JWT가 다음과 같이 안전할 수 있는 이유는 뭘까요? 구조를 보면 힌트를 얻을 수 있는데 구조는 다음과 같습니다.

출처 : https://velog.io/@fill0006/JWTJSON-Web-Token-%EA%B5%AC%EC%A1%B0-%EB%B0%8F-%EC%9D%B4%ED%95%B4

 

Header: 해싱할 때 사용한 알고리즘 정보와 토큰의 타입 정보를 포함

Payload: claim들이 들어있습니다. 주로 인증된 사용자 정보나 인가 정보등을 갖고 있습니다. 

Signature: 헤더와 페이로드를 인코딩한 후, 비밀 키를 사용하여 해상한 값입니다. 이는 토큰의 무결성과 출처를 확인하는 데 사용됩니다.

 

구조의 구분은 .으로 이루어져 위 그림같은 형태의 JWT가 생성됩니다. 

 

JWT의 인증 순서는 다음과 같습니다. 

요청 흐름도

 

1. Client가 JWT 정보를 Request Header에 Authorization Header에 담아 Bearer Token 형태로 WAS에 인증 요청을 보냅니다. 

2. Server는 JWT Filter에서 받아 1차적으로 인증을 시도합니다. 이 때 Authorization Header로부터 Bearer Token을 받아옵니다.

3. Bearer를 떼어낸 JWT 토큰만을 받아와 전자 서명정보와 유효한 토큰인지 확인합니다. 

3-1. 실패할 경우엔 401 UnAuthorization Error를 Client에 반환합니다.

4. 성공한 경우엔 Token안에 Claims를 열람하여 회원 정보를 가져옵니다. 열람한 정보를 갖고 이후 요청을 처리합니다.

 

그럼 코드를 보겠습니다. 

class JwtFilter(
    private val tokenProvider: TokenProvider,
    private val log: Logger
) : GenericFilterBean() {

    override fun doFilter(request: ServletRequest, response: ServletResponse, chain: FilterChain) {
        val httpServletRequest = request as HttpServletRequest
        val jwt = resolveToken(httpServletRequest)?: ""
        val requestURI = httpServletRequest.requestURI

        if (StringUtils.hasText(jwt) && tokenProvider.validateToken(jwt)) {
            val authentication = tokenProvider.getAuthentication(jwt)
            SecurityContextHolder.getContext().authentication = authentication
            log.debug("Security Context에 '{}' 인증 정보를 저장했습니다, uri: {}", authentication.name, requestURI)
        } else {
            log.debug("유효한 JWT 토큰이 없습니다, uri: {}", requestURI)
        }

        chain.doFilter(request, response)
    }

    private fun resolveToken(request: HttpServletRequest): String? {
        val bearerToken = request.getHeader(AUTHORIZATION_HEADER)

        return if (StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer ")) {
            bearerToken.substring(7)
        } else null
    }

    companion object {
        const val AUTHORIZATION_HEADER = "Authorization"
    }
}

 

 

위 코드는 HttpServletRequest로부터 jwt를 받아오고 이후 검증을 통해 authentication 정보를 받아오고 있습니다. 그리고 인증된 정보를 Security Context에 넣어준 후 다음 filter로 넘깁니다. 

 

그렇다면 검증과 authentication 정보는 어떻게 받아올까요? 

@Component
class TokenProvider(
    @param:Value("\${jwt.secret}") private val secret: String,
    @Value("\${jwt.accessToken-validity-in-seconds}") private val accessTokenValidityInSeconds: Long,
    @Value("\${jwt.refreshToken-validity-in-seconds}") private val refreshTokenValidityInSeconds: Long,
    private val customUserDetailsService: CustomUserDetailsService,
    private val log: Logger
) : InitializingBean {

    private val accessTokenValidityInMilliSeconds: Long = accessTokenValidityInSeconds * 1000
    private val refreshTokenValidityInMilliSeconds: Long = refreshTokenValidityInSeconds * 1000
    private var key: Key? = null

    override fun afterPropertiesSet() {
        key = Keys.hmacShaKeyFor(Decoders.BASE64.decode(secret))
    }

    fun createToken(memberId: Long, authentication: Authentication): TokenBaseDto {
        val authorities = authentication.authorities.stream()
            .map(GrantedAuthority::getAuthority)
            .collect(Collectors.joining(","))

        val accessTokenExpiredTime = Date(Date().time + accessTokenValidityInMilliSeconds)
        val refreshTokenExpiredTime = Date(Date().time + refreshTokenValidityInMilliSeconds)

        val accessToken = Jwts.builder()
            .claim("memberId", memberId.toString())
            .claim(AUTHORITIES_KEY, authorities)
            .setExpiration(accessTokenExpiredTime)
            .signWith(key, SignatureAlgorithm.HS512)
            .compact()

        val refreshToken = Jwts.builder()
            .setExpiration(refreshTokenExpiredTime)
            .signWith(key, SignatureAlgorithm.HS512)
            .compact()

        return TokenBaseDto(accessToken = accessToken, refreshToken = refreshToken)
    }

    fun getAuthentication(token: String): Authentication {
        val claims = Jwts.parserBuilder()
            .setSigningKey(key)
            .build()
            .parseClaimsJws(token)
            .body

        val authorities = Arrays.stream(claims[AUTHORITIES_KEY]
            .toString()
            .split(",".toRegex()).dropLastWhile { it.isEmpty() }
            .toTypedArray())
            .map { role: String? -> SimpleGrantedAuthority(role) }
            .collect(Collectors.toList())

        val memberId = claims["memberId"]

        val userDetails = customUserDetailsService.loadUserByUsername(memberId = memberId.toString())

        return UsernamePasswordAuthenticationToken(userDetails, "", authorities)
    }

    fun validateToken(token: String): Boolean {
        try {
            Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token)
            return true
        } catch (e: io.jsonwebtoken.security.SecurityException) {
            log.error("잘못된 JWT 서명입니다.")
        } catch (e: MalformedJwtException) {
            log.error("잘못된 JWT 서명입니다.")
        } catch (e: ExpiredJwtException) {
            log.error("만료된 JWT 토큰입니다.")
        } catch (e: UnsupportedJwtException) {
            log.error("지원되지 않는 JWT 토큰입니다.")
        } catch (e: IllegalArgumentException) {
            log.error("JWT 토큰이 잘못되었습니다.")
        }

        return false
    }
}

 

토큰 검증: 받아온 토큰을 서명정보와 만료 여부 지원 여부등을 판단하여 에러 로그를 찍고 있습니다. 이 때 false 를 반환하고 Exception을 반환 하지 않는 이유는 단순합니다. 인증이 필요 없는 요청도 반드시 JWT Filter를 타게 되는데 이때 인증이 필요 없는 경우에도 Exception으로 인해 서비스를 이용하지 못하는 경우를 방지하기 위함 입니다. 

 

토큰 열람: 비밀키를 이용해 decode한 후 내부 claims 정보를 parsing 하여 열람합니다. 이 때 인증 및 인가 정보를 가져와 반환합니다. 

 

그럼 이제 JWT 정의 부터 구현까지 모두 살펴 보았습니다. 그럼 마무리 Q&A를 하면서 마치도록 하겠습니다.

마무리 Q & A

Q1: 폐기된 인증 정보로 해커가 인증을 시도한 경우는 어떡하나요? 

 

보통 이런 경우에 대비하여 로그인 좌표나 인증 기기의 정보 등을 파악하여 비정상적인 로그인이라 판단될 경우 추가 인증을 요구하거나 본 사용자의 타 기기로 알림을 주는 방식으로 방지할 수 있습니다. 블랙 리스트를 사용해도 되지만 이는 stateful 하기 때문에 stateless라는 장점을 취하기 위한 목적으로 JWT를 생각하셨다면 JWT 사용 목적 자체가 모호해질 수 있습니다. 

 

또한 session도 결국 session을 받아오기 위한 session ID 정보를 탈취하여 인증 시도가 가능하기 때문에 이러한 열람 가능한 정보는 유효시간을 짧게주어 해킹으로 부터 예방하는 것이 좋습니다. 

 

Q2: 그러면 인증 되어있는 시간이 너무 짧아 UX가 안좋아지지 않을까요?

 

Access Token의 짧은 유효 기간으로 인해 UX가 내려갈 수 있습니다. 이를 해결하기 위해 Refresh Token을 고려해보 수 있습니다. Refresh Token은 사용자 정보를 들고 있지 않은 유효시간이 긴 Token으로 만들어 Access Token 재발급이 가능합니다. 또는 인가 정보를 제외한 최소한의 정보를 담은 Refresh Token으로 만들 수도 있습니다. 

 

Q3: MSA에서 JWT를 쓰면 유리한가요?

 

상황에 따라 다르다고 말씀드리고 싶습니다. 인증 인가로 들어가는 정보가 적어 저장소의 도움없이 인증 인가를 처리할 수 있는 환경이면 stateless한 HTTP 프로토콜 특성을 이용하며 자유롭게 여러 서버에서 인증 인가를 할 수 있기 때문에 유리할 수 있지만 저장소를 다녀와야하는 상황이라면 여러 서버가 인증 인가를 저장소로 부터 받아와 검증하는 코드를 마구잡이로 넣어줘야하니 session과 크게 다를 것 없이 StateFul한 특성을 갖는 인증 방식이 됩니다. 

GitHub

https://github.com/ilgolf/blog_for_developer

 

GitHub - ilgolf/blog_for_developer: 개발자를 위한 게시판 서비스 toy project contribute 해주셔도 됩니다.

개발자를 위한 게시판 서비스 toy project contribute 해주셔도 됩니다. Contribute to ilgolf/blog_for_developer development by creating an account on GitHub.

github.com

 

댓글
공지사항
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday
링크
«   2024/09   »
1 2 3 4 5 6 7
8 9 10 11 12 13 14
15 16 17 18 19 20 21
22 23 24 25 26 27 28
29 30
글 보관함