𝐖𝐞𝐛𝐒𝐨𝐜𝐤𝐞𝐭 통신에서 𝐀𝐜𝐜𝐞𝐬𝐬𝐓𝐨𝐤𝐞𝐧 재발급 받기 (최초 실행시)

2025. 1. 23. 16:32TIL

일반 페이지에서는 Filter를 통해서 해당 토큰을 검증하고 만약 유효기간이 끝났다면 HTTP ONLY 쿠키에 저장되어있는 RefreshToken을 이용하여 AccessToken을 재발급 받는 형태로 사용자 정보를 유지하고 있다.

JwtAuthenticationFilter (HTTP 필터)

더보기
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
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)
        }

        var refreshToken: String? = null

        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) {
                    logger.info("토큰의 만료기간이 끝나서 validate되지 않았음!! ")
                    refreshToken = getRefreshTokenFromCookies(request)
                    logger.info("쿠키에서 가져온 refreshTOken값: $refreshToken")

                    if(refreshToken != null) {
                        logger.info(" 토큰의 만료기간이 지났고 refreshToken이 null이 아님!")
                        jwtTokenManager.validateRefreshToken(refreshToken!!)
                        val newAccessToken = jwtTokenManager.reissueAccessToken(refreshToken!!)
                        response.setHeader(HttpHeaders.AUTHORIZATION, "Bearer $newAccessToken")
                        logger.info("토큰 재발급 성공! $newAccessToken")
                        val tokenInfo = jwtTokenManager.getInfoToken(newAccessToken)
                        val userPrincipal = UserPrincipal(memberId = tokenInfo!!.memberId, memberRole = setOf(tokenInfo.memberRole))
                        val authentication = JwtAuthenticationToken(
                            userPrincipal = userPrincipal, details = WebAuthenticationDetailsSource().buildDetails(request)
                        )
                        SecurityContextHolder.getContext().authentication = authentication
                    }

                }
                else response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "인증 실패")

            }


        }

        filterChain.doFilter(request, response)
    }
}

private fun getRefreshTokenFromCookies(request: HttpServletRequest): String? {
    return request.cookies
        ?.firstOrNull { it.name == "refreshToken" }
        ?.value
}

 

 JwtWebSocketHandshakeIntercepter (웹소켓 필터)

더보기
package hjp.hjchat.infra.security.jwt.websocket

import hjp.hjchat.infra.security.jwt.JwtAuthenticationToken
import hjp.hjchat.infra.security.jwt.JwtTokenManager
import hjp.hjchat.infra.security.jwt.UserPrincipal
import org.springframework.http.server.ServerHttpRequest
import org.springframework.http.server.ServerHttpResponse
import org.springframework.security.core.context.SecurityContextHolder
import org.springframework.web.socket.WebSocketHandler
import org.springframework.web.socket.server.HandshakeInterceptor
import org.springframework.stereotype.Component
import java.nio.file.AccessDeniedException


@Component
class JwtWebSocketHandshakeInterceptor(
    private val jwtTokenManager: JwtTokenManager,
) : HandshakeInterceptor {

    override fun beforeHandshake(
        request: ServerHttpRequest,
        response: ServerHttpResponse,
        wsHandler: WebSocketHandler,
        attributes: MutableMap<String, Any>
    ): Boolean {
        val queryParams = request.uri.query
        val token = queryParams?.substringAfter("token=")?.substringBefore("&")

        if (token.isNullOrBlank()) {
            response.headers.set("Close-Code","4001")
            throw AccessDeniedException("JWT Token is missing in query parameters")
        }

        jwtTokenManager.validateToken(token).onSuccess { claims ->
            val memberId = claims.body.subject.toLong()
            val memberRole = claims.body.get("memberRole", String::class.java)

            val userPrincipal = UserPrincipal(
                memberId = memberId,
                memberRole = setOf(memberRole)
            )


            val authentication = JwtAuthenticationToken(
                userPrincipal = userPrincipal,
                details = null
            )

            SecurityContextHolder.getContext().authentication = authentication
            attributes["userPrincipal"] = userPrincipal
        }.onFailure {
            response.headers.set("Close-Code", "4001")
            throw AccessDeniedException("Invalid JWT Token")
        }

        return true
    }


    override fun afterHandshake(
        request: ServerHttpRequest,
        response: ServerHttpResponse,
        wsHandler: WebSocketHandler,
        exception: Exception?
    ) {

    }
}

 

하지만 웹소켓은 HTTP통신과 다르기 때문에 따로 로직을 작성해줘야한다. 위 사진을 보면 토큰의 만료기간이 끝나면  검증되지 않는 토큰이기 때문에 웹소켓 연결에 실패하는것을 확인할수 있다.

 

 

토큰의 유효기간이 끝난것을 확인 할수있다.

 

 

WebSocket은 HTTP와 다르게 지속적으로 유지되기때문에 다른 방법을 사용해야한다.

WebSocket 연결이 accessToken의 만료시간때문에 실패했다면 HTTP 통신을 이용하여 AccessToken을 재발급 받고 다시 웹소켓을 연결하는 방식으로 구현해 보았다.

 

재발급 로직은 이미 작성해두었기때문에  엔드포인트를 통해서 클라이언트가 재발급 요청을 할수있도록 Controller와 Service 를 작성

Controller

    @PostMapping("reissue")
    fun reissueToken(
        @CookieValue("refreshToken") refreshToken: String
    ): ResponseEntity<AccessTokenDto> {
        return ResponseEntity.status(HttpStatus.OK).body(oAuthService.reissueToken(refreshToken))
    }

Service

    fun reissueToken(refreshToken: String): AccessTokenDto {
        return AccessTokenDto( accessToken = jwtTokenManager.reissueAccessToken(refreshToken))
    }

 

WebSocket Handler 작성

더보기
package hjp.hjchat.infra.security.jwt.websocket


import org.springframework.web.socket.WebSocketSession
import org.springframework.web.socket.handler.WebSocketHandlerDecorator
import org.springframework.web.socket.handler.WebSocketHandlerDecoratorFactory
import org.springframework.stereotype.Component
import org.springframework.web.socket.CloseStatus
import org.springframework.web.socket.WebSocketHandler

@Component
class CustomWebSocketHandlerDecoratorFactory : WebSocketHandlerDecoratorFactory {

    override fun decorate(handler: WebSocketHandler): WebSocketHandler {
        return object : WebSocketHandlerDecorator(handler) {
            override fun afterConnectionEstablished(session: WebSocketSession) {
                val tokenValid = session.attributes["tokenValid"] as? Boolean ?: false

                println(" ------------------tokenValid값:$tokenValid ----------------------")
                if (!tokenValid) {
                    println("🔒 유효하지 않은 토큰 - WebSocket 연결 종료")
                    session.close(CloseStatus(4001, "Invalid JWT Token"))
                    // 🔒 연결 종료
                    return
                }

                println("✅ WebSocket 연결 성공")
                super.afterConnectionEstablished(session)
            }
        }
    }
}

 

CustomWebSocketHandlerDecoratorFactory 해당 class를 통해서 유효기간 만료로 인해서 웹소켓 연결에 실패할 경우에 클라이언트로 4001 코드를 보낸다.

 

Client

더보기
function initializeWebSocket() {
    const socket = new SockJS(`https://localhost:443/ws?token=${accessToken}`);
    ws = Stomp.over(socket);

    ws.connect({}, function (frame) {
        console.log("WebSocket 연결 성공:", frame);
        if (currentChatRoomId) {
            ws.subscribe(`/topic/chatroom/${currentChatRoomId}`, function (message) {
                displayMessage(JSON.parse(message.body));
            });
        }
    }, function (error) {
        console.error("WebSocket 연결 실패:", error);
    });

    socket.onclose = function (event) {
        console.warn(`WebSocket 연결 종료. 코드: ${event.code}, 이유: ${event.reason}`);

        // ✅ 토큰 만료(4001) 시 Access Token 재발급 시도
        if (event.code === 4001) {
            reissueAccessToken().then(() => {
                console.log("🔑 토큰 재발급 성공, 웹소켓 재연결 시도");
                initializeWebSocket();  // 🔄 웹소켓 재연결
            }).catch(() => {
                alert("세션이 만료되었습니다. 다시 로그인 해주세요.");
                window.location.href = "./sign-in.html";  // ❌ 실패 시 로그인 페이지로 이동
            });
        } else {
            console.error("예상치 못한 웹소켓 종료입니다.");
        }
    };

    // ❗ 에러 발생 시 단순 로그 출력
    socket.onerror = function (error) {
        console.error("WebSocket 오류 발생:", error);
    };

}

// ✅ AccessToken 재발급
async function reissueAccessToken() {
    const response = await fetch('https://localhost:443/api/oauth/reissue', {
        method: 'POST',
        credentials: 'include',  // ✅ HttpOnly 쿠키 전송
    });

    if (response.ok) {
        const data = await response.json();
        const newAccessToken = data.accessToken;
        localStorage.setItem("accessToken", newAccessToken);
        accessToken = newAccessToken
        console.log("🔑 AccessToken 갱신 성공");
    } else {
        throw new Error("AccessToken 갱신 실패");
    }
}

클라이언트가 서버로부터 4001코드를 받았다면 reissueAccessToken()함수를 통해 refreshToken으로 AccessToken을 재발급 받는다. 재발급 받은 코드를 localStorage에 저장하고 다시 웹소켓 연결을 시도한다.

 

클라이언트 로그

 

서버 로그

유효하지 않는 accessToken일 경우 웹소켓 연결을 종료하고 refreshToken을 통해 accessToken을 재발급 받을 경우 다시 연결에 성공한다.