Sᴘʀɪɴɢ ꜱᴇᴄᴜʀɪᴛʏ와 Sᴘʀɪɴɢ GʀᴀᴘʜQL 통합

2024. 12. 17. 17:21TIL

기존 코드

@MutationMapping
fun sendMessage(
    @Argument content: String,
    @RequestHeader(HttpHeaders.AUTHORIZATION) authorizationHeader: String
): MessageDto {

    val token = authorizationHeader.removePrefix("Bearer ").trim()
    val userId = jwtTokenManager.extractUserId(token)

    val user = oAuthRepository.findById(userId)
        .orElseThrow { IllegalArgumentException("Member not found with id: $userId") }

    val savedMessage = messageRepository.save(
        Message(
            content = content,
            userId = user,
        )
    )
    return savedMessage.toResponse()
}

 

graphiql에서 테스트시 실패

 

원인은 @RequestHeader가 Graphql 리졸브에서 지원하지 않는 파라미터로 간주되었기 때문.

Spring security와 Spring GraphQL을 통합하여 JWT 토큰을 검증하고 사용자 정보를 가져오는 식으로 변경한다.

 

Spring security, Spring GraphQL 통합

package hjp.hjchat.domain.chat.controller

import graphql.kickstart.tools.GraphQLMutationResolver
import graphql.kickstart.tools.GraphQLQueryResolver
import hjp.hjchat.domain.chat.dto.MessageDto
import hjp.hjchat.domain.chat.entity.Message
import hjp.hjchat.domain.chat.entity.toResponse
import hjp.hjchat.domain.chat.model.MessageRepository
import hjp.hjchat.infra.security.jwt.UserPrincipal
import hjp.hjchat.infra.security.ouath.model.OAuthRepository
import org.springframework.graphql.data.method.annotation.Argument
import org.springframework.graphql.data.method.annotation.MutationMapping
import org.springframework.graphql.data.method.annotation.QueryMapping
import org.springframework.security.core.annotation.AuthenticationPrincipal
import org.springframework.stereotype.Controller


@Controller
class ChatController(
    private val messageRepository: MessageRepository,
    private val oAuthRepository: OAuthRepository,
) : GraphQLQueryResolver, GraphQLMutationResolver {

    @QueryMapping
    fun getMessages(): List<MessageDto> {
        return messageRepository.findAll().map { it.toResponse() }
    }

    @MutationMapping
    fun sendMessage(
        @AuthenticationPrincipal user: UserPrincipal,
        @Argument content: String
    ): MessageDto {

        val member = oAuthRepository.findById(user.memberId)
            .orElseThrow { IllegalArgumentException("Member not found") }

        val savedMessage = messageRepository.save(
            Message(
                content = content,
                userId = member
            )
        )
        return savedMessage.toResponse()
    }
}

 

UserPrinciapl

data class UserPrincipal(
    val memberId: Long,
    val authorities: Collection<GrantedAuthority>
) {

    constructor(memberId: Long, memberRole: Set<String>) : this(
        memberId,
        memberRole.map { SimpleGrantedAuthority("ROLE_$it") }
    )

    val role: String = authorities.first().authority ?: "ROLE_UNKNOWN"
}

 

시스템 흐름

 

1. 클라이언트가 HTTP 헤더에 Authorization 헤더를 포함시켜 AccessToken을 담아서 서버로 요청을 보낸다.

더보기

Authorization: Bearer <Access token 값>

2-1. 서버는 해당 요청을 받고 Authorization 헤더에 포함된 jwt token을 추출하며 jwtTokenManager의 validateToken 함수로 해당 토큰을 검증한다.

더보기
package hjp.hjchat.infra.security.jwt
import io.jsonwebtoken.ExpiredJwtException
import jakarta.servlet.FilterChain
import jakarta.servlet.http.HttpServletRequest
import jakarta.servlet.http.HttpServletResponse
import org.springframework.http.HttpHeaders.AUTHORIZATION
import org.springframework.security.core.context.SecurityContextHolder
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource
import org.springframework.stereotype.Component
import org.springframework.web.filter.OncePerRequestFilter

@Component
class JwtAuthenticationFilter(
    private val jwtTokenManager: JwtTokenManager,
) : OncePerRequestFilter() {
    override fun doFilterInternal(
        request: HttpServletRequest, response: HttpServletResponse, filterChain: FilterChain
    ) {
        var pureToken: String? = null

        if (request.getHeader(AUTHORIZATION) != null && request.getHeader(AUTHORIZATION).startsWith("Bearer ")) {
            pureToken = request.getHeader("Authorization").substring(7)
        }

        if (pureToken != null) {
            jwtTokenManager.validateToken(pureToken).onSuccess {

                val tokenType = it.payload.get(JwtTokenManager.TOKEN_TYPE_KEY, String::class.java)
                val memberRole = it.payload.get(JwtTokenManager.MEMBER_ROLE_KEY, String::class.java)
                val memberId: Long = it.payload.subject.toLong()

                if (tokenType == TokenType.REFRESH_TOKEN_TYPE.name) {
                    return@onSuccess
                }

                val userPrincipal = UserPrincipal(memberId = memberId, memberRole = setOf(memberRole))
                val authentication = JwtAuthenticationToken(
                    userPrincipal = userPrincipal, details = WebAuthenticationDetailsSource().buildDetails(request)
                )

                SecurityContextHolder.getContext().authentication = authentication
            }.onFailure {
                logger.debug("Token validation failed", it)
                if (it is ExpiredJwtException) {
                    response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "Token expired")

                    return
                }
            }
        }

        filterChain.doFilter(request, response)
    }
}

2-2. 토큰이 유효하면 서버는JwtTokenAuthenticationToken 객체를 생성하고  토큰 안에 저장된 정보(memberRole,memberId)를 파싱해서 사용자의 정보를 추출한다. 이 정보는 UserPrincipal 객체로 담기게 된다.

더보기
class JwtAuthenticationToken(
    private val userPrincipal: UserPrincipal, details: WebAuthenticationDetails
) : AbstractAuthenticationToken(userPrincipal.authorities) {

    init {
        super.setAuthenticated(true)
        super.setDetails(details)
    }

    override fun getCredentials() = null

    override fun getPrincipal() = userPrincipal

    override fun isAuthenticated(): Boolean = true
}

 

2-3 Spring Security는 SecurityContextHolder에 인증된 사용자의 정보(Authentication)을 설정하며 인증된 사용자는 @AuthenticationPrincipal 어노테이션을 이용해서 접근이 가능하다.

 

3.  요청을 처리하는 GraphQL 리졸브에서 @AuthenticationPrincipal 어노테이션을 이용하여 사용자 정보를 접근한다.

더보기
@MutationMapping
    fun sendMessage(
        @AuthenticationPrincipal user: UserPrincipal,
        @Argument content: String
    ): MessageDto {

        val member = oAuthRepository.findById(user.memberId)
            .orElseThrow { IllegalArgumentException("Member not found") }

        val savedMessage = messageRepository.save(
            Message(
                content = content,
                userId = member
            )
        )
        return savedMessage.toResponse()
    }

 

Postman에서 테스트