[Node.js] Access Token과 Refresh Token으로 로그인 유지하기

HoonDong_K·2023년 1월 29일
1

[project #1] movie-inner

목록 보기
4/10
post-custom-banner

Token 시스템을 도입한 계기


유저가 로그인을 하고 다른 페이지로 이동하였을 때, 브라우저 입장에서는 유저의 정보를 담아놓을 필요가 있다. 이번에 진행하였던 프로젝트에서 유저의 정보를 담아놓아야 하는 이유는 크게 두 가지로 생각해볼 수 있다.

  1. 유저가 로그인이 필수적인 페이지에 접속했을 경우
  2. 유저의 정보를 매개변수로 보내야하는 API를 동작시켰을 경우

1번의 경우, '마이페이지' 부분이 이에 해당된다. 유저별로 담겨지는 정보가 달라지는 마이페이지의 특성상 비회원 또는 로그인을 하지 않은 유저는 이 페이지에 접속할 수 없으며, 이 페이지에 접속하기 위해서는 로그인을 했다는 인증이 지속되어야 한다.

2번의 경우, '커뮤니티' 또는 '좋아요' 버튼 등의 부분들이 해당된다. 이 경우, API의 종류가 많아 사례가 다양하겠지만 대표적으로 두 가지를 생각하였다. 커뮤니티의 경우, 유저 인증 여부가 지속되지 않더라도 페이지에 접속하여 글을 관람할 수 있는 권한은 있다. 하지만 글을 작성하거나 댓글을 다는 행위는 유저의 정보가 필수적으로 포함되어야 하기 때문에 브라우저에서 유저의 정보를 갖고 있어야 했다. '좋아요' 버튼 또한, 영화나 테마를 '누가' 좋아하는 지 알아야 하기 때문에 이 부분도 전자와 동일하다고 볼 수 있다.

Access Token 과 Refresh Token


Access Token : 유저의 정보를 실질적으로 담고 있는 토큰

  • 유저의 정보를 담고 있어 보안이 중요하다.
  • 보안을 강화하기 위해서 유효성을 짧게 설정하고, 재발급 받도록 설정한다.

Refresh Token : Access Token을 재발급해주기 위한 토큰

  • Access Token에 비해 유효 기간이 길다.
  • Access Token의 보안성을 높인다.

Access Token은 유효기간을 30분에서 1시간으로 설정하여 그 시간동안은 로그인을 유지할 수 있지만 만료가 되었을 시, 로그아웃된다.

Refresh Token은 유효기간을 더 길게 설정 ( 이번 프로젝트에서 6달로 설정 )하여 그 기간 내에 Acces Token 을 재발급시켜줄 수 있다. Refresh Token이 만료 시 로그아웃되며, 재로그인을 하면 Refresh Token이 재발급된다.

유저가 로그인 또는 회원가입을 성공적으로 마쳤을 때, 서버는 유저의 정보를 payload로 담은 Access Token그 Access Token을 payload로 담은 Refresh Token을 새로 발급한다. 이후, Client의 쿠키에 Refresh Token을 저장하고, DB에도 Refresh Token을 저장한다.

로그인 인증이 필요한 API 요청의 경우, Refresh Token 속 Access Token을 header로 보내 권한을 요청 하고 DB에 저장된 Access Token과 동일할 시 허용한다.

만약 만료된 Access Token이 담겨졌다면 재발급 요청을 통해 새로운 토큰을 발급받고 로그인을 유지할 수 있다.

프로젝트 실제 구현


Token을 제작하기 위해 우리는 JWT(Json Web Token)을 사용하였다.

$ npm install jsonwebtoken
  • Access Token 유효 기간 : 1시간
    ( Front-end에서 setTimeout을 통해 재발급 요청 )

  • Access Token의 payload : 유저 idx, email, nickname

  • Refresh Token의 payload : Access Token, Refresh Token 유효기간

//JWT 토큰 발급
const generateToken = async (
    params: { email: string },
    connection: DbConnection
) => {
    let accessToken = ""
    let refreshToken = ""
    const refreshTokenExpiredDate = new Date(
        Date.now() + 3600 * 1000 * 24 * 180
    )
    try {
        const { email } = params
        // email에 해당하는 유저의 idx,nickname 추출
        const response = await connection.run(
            `SELECT idx, nickname FROM user_info WHERE email=?`,
            [email]
        )
        const { idx, nickname } = response[0]
		// Access Token 생성
        const accessTokenPayload = { email, idx, nickname }
        accessToken = JWT.sign(accessTokenPayload, JWT_SECRET)
		// Refresh Token 생성
        const refreshTokenPayload = { accessToken, refreshTokenExpiredDate }
        refreshToken = JWT.sign(refreshTokenPayload, JWT_SECRET)
      
      	// 한번도 토큰을 발급하지 않았을 경우 INSERT, 그 외 UPDATE
        const getResponse = await connection.run(
            `SELECT COUNT(*) as count from user_token WHERE email=?`,
            [email]
        )
        const { count } = getResponse[0]
        if (count > 0) {
            await connection.run(
                `UPDATE user_token SET access_token=?,refresh_token=?,refresh_token_expires_in=? WHERE email=?`,
                [accessToken, refreshToken, refreshTokenExpiredDate, email]
            )
        } else {
            await connection.run(
                `INSERT INTO user_token(email,access_token,refresh_token,refresh_token_expires_in) VALUES(?,?,?,?)`,
                [email, accessToken, refreshToken, refreshTokenExpiredDate]
            )
        }
    } catch (e: any) {
        paramsErrorHandler(e)
    }
    return {
        status: 201,
       //cookie에 Refresh Token 저장
        cookie: {
            name: "refreshToken",
            val: refreshToken,
            options: {
                httpOnly: true,
                path: "/",
                sameSite: "lax",
            },
        },
        data: {
            success: true,
            accessToken,
        },
    }
}

성공적으로 cookie에 Refresh Token을 저장하였다.

유저가 로그인을 하고 토큰이 발급되었고, 쿠키에 저장된 토큰을 통해 유저는 로그인을 유지하며 유저에 대한 정보를 갖고 있을 수 있게 되었다.

하지만 토큰은 평생 지속되지 않는다. Access Token은 한 시간 뒤에 자동적으로 재발급 요청을 보내게 설정해두었다. 요청을 받았을 때, 토큰을 재발급해주는 코드를 보여주겠다. 변수도 많고 깔끔하지도 않아서 참고만 하자

interface RefreshTokenPayloadType {
    accessToken: string
    refreshTokenExpiredDate: string
    iat: number
}

interface AccessTokenPayloadType {
    email: string
    idx: string
    nickname: string
    iat: number
}

const refreshToken = async (params: any, connection: DbConnection) => {
   // 초기화
    let refreshTokenPayload: RefreshTokenPayloadType = {
        accessToken: "",
        refreshTokenExpiredDate: "",
        iat: 0,
    }
    let newAccessTokenPayload = {}
    let newAccessToken = ""
    let newRefreshTokenPayload = {}
    let newRefreshToken = ""
    let newRefreshTokenExpireIn = new Date()
    const NewRefreshTokenExpiredDate = new Date( //6개월
        Date.now() + 3600 * 1000 * 24 * 180
    )

    // access Token expiry 기간이 지나거나
    // access Token 이 header에서 사라지면 재발급

    try {
      //cookie를 통해 자동적으로 param으로 보내짐
        const { refreshToken } = params
        
        //갖고있던 Refresh Token을 분해하여 payload와 Access Token 추출 
        refreshTokenPayload = JWT.verify(
            refreshToken,
            JWT_SECRET
        ) as RefreshTokenPayloadType
        const { accessToken, refreshTokenExpiredDate } = refreshTokenPayload
        
        //Access Token에서 유저 정보 추출 
        const accessTokenPayload = JWT.verify(
            accessToken,
            JWT_SECRET
        ) as AccessTokenPayloadType
        const { email, idx, nickname } = accessTokenPayload
        
        const now = Date.now()
        const getResponse = await connection.run(
            `SELECT COUNT(*) as count FROM user_token WHERE refresh_token=?`,
            [refreshToken]
        )
        const { count } = getResponse[0]
        //Refresh Token이 DB에 존재할 경우
        if (count > 0) {
            if (
                // refresh Token 만료가 1달 이상일 경우
                Date.parse(refreshTokenExpiredDate) >
                now + 3600 * 1000 * 24 * 30
            ) {
                //기존 Refresh Token 유효 기간 설정
                newAccessTokenPayload = { email, idx, nickname }
                newAccessToken = JWT.sign(newAccessTokenPayload, JWT_SECRET)
                newRefreshTokenPayload = {
                    accessToken: newAccessToken,
                    refreshTokenExpiredDate,
                }
           
                newRefreshToken = JWT.sign(newRefreshTokenPayload, JWT_SECRET)
                newRefreshTokenExpireIn = new Date(refreshTokenExpiredDate)
            } else {
                // 새로운 refresh token 유효 기간 설정
                newAccessTokenPayload = { email, idx, nickname }
                newAccessToken = JWT.sign(newAccessTokenPayload, JWT_SECRET)
                newRefreshTokenPayload = {
                    accessToken: newAccessToken,
                    NewRefreshTokenExpiredDate,
                }
              
                newRefreshToken = JWT.sign(newRefreshTokenPayload, JWT_SECRET)
                newRefreshTokenExpireIn = new Date(NewRefreshTokenExpiredDate)
            }
          //DB 업데이트
            await connection.run(
                `UPDATE user_token SET access_token=?,refresh_token=?,refresh_token_expires_in=? WHERE refresh_token=?`,
                [
                    newAccessToken,
                    newRefreshToken,
                    newRefreshTokenExpireIn,
                    refreshToken,
                ]
            )
        } else {
            throw "E0005"
        }
    } catch (e: any) {
        throw new Error(e)
    }
	// cookie에 새로운 Refresh Token 업데이트
    return {
        status: 201,
        cookie: {
            name: "refreshToken",
            val: newRefreshToken,
            options: { httpOnly: true, path: "/", sameSite: "lax" },
        },
        data: {
            accessToken: newAccessToken,
            refreshTokenExpireIn: newRefreshTokenExpireIn,
        },
    }
}

새로운 Access Token과 조건에 맞는 Refresh Token의 만료 기간이 생겼다

profile
개발을 잘하고 싶은 개발자
post-custom-banner

0개의 댓글