유저가 로그인을 하고 다른 페이지로 이동하였을 때, 브라우저 입장에서는 유저의 정보를 담아놓을 필요가 있다. 이번에 진행하였던 프로젝트에서 유저의 정보를 담아놓아야 하는 이유는 크게 두 가지로 생각해볼 수 있다.
1번의 경우, '마이페이지' 부분이 이에 해당된다. 유저별로 담겨지는 정보가 달라지는 마이페이지의 특성상 비회원 또는 로그인을 하지 않은 유저는 이 페이지에 접속할 수 없으며, 이 페이지에 접속하기 위해서는 로그인을 했다는 인증이 지속되어야 한다.
2번의 경우, '커뮤니티' 또는 '좋아요' 버튼 등의 부분들이 해당된다. 이 경우, API의 종류가 많아 사례가 다양하겠지만 대표적으로 두 가지를 생각하였다. 커뮤니티의 경우, 유저 인증 여부가 지속되지 않더라도 페이지에 접속하여 글을 관람할 수 있는 권한은 있다. 하지만 글을 작성하거나 댓글을 다는 행위는 유저의 정보가 필수적으로 포함되어야 하기 때문에 브라우저에서 유저의 정보를 갖고 있어야 했다. '좋아요' 버튼 또한, 영화나 테마를 '누가' 좋아하는 지 알아야 하기 때문에 이 부분도 전자와 동일하다고 볼 수 있다.
Access Token : 유저의 정보를 실질적으로 담고 있는 토큰
Refresh 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의 만료 기간이 생겼다