원래는 엑세스 토큰하나만 생성하여 검증하여 게임을 진행하였습니다. 하지만 리프레쉬 토큰도 같이 생성해서 리프레시 토큰을 이용하여 엑세스 토큰을 생성하는 것이 좋을 것 같아 구현해 보았습니다.
원래 처음에 그냥 엑세스 토큰을 게임 실행 중에 중간마다 엑세스 토큰을 새로 갱신하는 것만을 구현해 보았습니다. 그런데 리프레시 토큰에 대해 몰랐다가 팀원분들이 알려주셔서 찾아보면서 다시 구현해 보았습니다.
이번에 리프레시와 엑세스 토큰 구조에대해 제대로 공부한 느낌입니다. 굿!
로그인시
먼저 로그인 API 함수에 리프레시 토큰 생성 부분을 추가해 주었습니다.
const token = jwt.sign({ accountId: existingAccount.id }, process.env.SESSION_SECRET_KEY, { expiresIn: '10m', }); const refreshToken = jwt.sign( { accountId: existingAccount.id }, process.env.REFRESH_TOKEN_KEY, { expiresIn: '1d', }, ); res.header('authorization', `Bearer ${token}`); res.cookie('refreshToken', refreshToken, { httpOnly: true, secure: false, // HTTPS 환경에서만 전송 나중에 배포시에 수정 maxAge: 1 * 24 * 60 * 60 * 1000, });
생성한 리프레시 토큰은 쿠키에 넣어 주었습니다.
사용한 쿠키설정 옵션들입니다.
- httpOnly : 클라이언트 측(JavaScript)에서 쿠키에 접근할 수 없도록 제한합니다.
- 용도: XSS(Cross-site Scripting) 공격 방어에 도움이 된다고 합니다.
- secure : HTTPS 환경에서만 쿠키가 전송되도록 설정합니다.
- 용도: 안전한 HTTPS 연결에서만 쿠키가 오가도록 보장해 보안을 강화합니다.
- HTTPS로 배포시에는 반드시 true로 설정해야 합니다.
- maxAge : 쿠키의 만료 시간(밀리초 단위)입니다. 설정된 시간 이후 쿠키가 삭제됩니다.
- 예시: 1 24 60 60 1000은 1일(24시간) 동안 유효하다는 의미입니다.
- 쿠키에 리프레시 토큰을 긴 만료기간을 갖고 생성합니다.
- 헤더에 엑세스 토큰을 짧은 만료기간을 갖고 생성합니다.
게임 실행시
바로 엑세스 토큰을 생성을 생성하는 함수를 실행합니다.
export function extendAccessToken() { fetch('/api/tokenextend', { method: 'POST', credentials: 'include', // 쿠키를 포함한 요청 headers: { 'Content-Type': 'application/json', }, }) .then(async (response) => { const resData = await response.json(); if (!response.ok) throw new Error(resData.message); const token = response.headers.get('Authorization'); localStorage.setItem('jwt', token); console.log(resData.message); }) .catch((error) => { console.error('엑세스 토큰 갱신 중 에러:', error); alert('리프레쉬 토큰이 만료되었습니다. 다시 로그인해 주세요'); window.location.href = 'index.html'; }); }
이 함수를 통해 API를 호출합니다.
credentials: 'include'
: 쿠키의 정보를 같이 전달하는 의미의 옵션입니다.
호출된 리프레시 토큰 검증과 엑세스 토큰 갱신 API함수
export async function tokenExtension(req, res, next) { try { const { refreshToken } = req.cookies; // 리프레쉬 토큰 검증하기 if (!refreshToken) throw throwError('발급받은 리프레쉬 토큰이 없습니다.', 401); const id = jwt.verify(refreshToken, process.env.REFRESH_TOKEN_KEY).accountId; const existingAccount = await accountService.findAccountById(id); // error: 계정이 존재하지 않는 경우 if (!existingAccount) throw throwError('계정이 존재 하지 않습니다.', 404); // 새롭게 토큰 갱신하여 연장하기 const token = jwt.sign({ accountId: existingAccount.id }, process.env.SESSION_SECRET_KEY, { expiresIn: '10m', }); res.header('authorization', `Bearer ${token}`); return res.status(201).json({ message: '액세스 토큰 갱신 성공' }); } catch (error) { next(error); } }
- 먼저 리프레시 토큰을 검증합니다.
- 리프레시 토큰이 유효하다면 액세스 토큰을 새로 생성해 줍니다.
- 생성한 엑세스 토큰은 헤더에 다시 갱신해 줍니다.
주의
cookie-parser 모듈을 설치하여야req.cookies
이 부분에서 제대로 쿠키의 데이터가 파싱되어 값을 사용할 수 있습니다.
socket연결 시도
클라이언트
연결할 서버의 주소를 통해 query에 정보를 담아 보내줍니다.
serverSocket = io(SERVER_URL, { query: { clientVersion: CLIENT_VERSION, token: localStorage.getItem('jwt'), }, });
서버
연결된 서버에선 넘겨 받은 토큰을 검증을 먼저 진행합니다.
const registerHandler = (io) => { // 유저 접속시 (대기하는 함수) io.on('connection', async (socket) => { const authorization = socket.handshake.query.token; // 토큰 존재 여부 if (!authorization) throw new Error('요청한 사용자의 토큰이 존재하지 않습니다.'); // 토큰 타입 확인 const [tokenType, token] = authorization.split(' '); console.log(tokenType); if (tokenType !== 'Bearer') throw new Error('토큰 타입이 Bearer 형식이 아닙니다.'); // 토큰 검증 let decodedToken; try { // 토큰 검증 decodedToken = jsonwebtoken.verify(token, process.env.SESSION_SECRET_KEY); } catch (error) { if (error.name === 'TokenExpiredError') { socket.emit('tokenExpired', { message: '토큰이 만료되었습니다.', }); } else { socket.emit('error', { message: '유효하지 않은 토큰입니다.', }); } return socket.disconnect(); } ... ...
처음으로 리프레시 토큰을 이용한 구조를 구현해 보았습니다. 처음에 아에 감이 안 잡혀서 그럼 이걸 왜 써야되는지부터 생각해 보았습니다. 아 보안문제 때문이였구나라는 것을 알고보니 흐름이 어느정도 파악이 되는 것 같았습니다. 그래서 엑세스 토큰을 헤더에 저장되어 있으니 만료 기간을 짧게 설정하는 것도 이해가 되었습니다.
그리고 약간 몇가지 검증이 들어가면 좋겠다는 생각도 드네요. 엑세스 토큰이 아직 만료되지 않았다면 갱신해주지 않는다라던가, 기존에 있던 엑세스 토큰을 한번 더 검증을 한다던가, 일단 이정도로 추가해주어도 좋을 것 같다는 생각이듭니다. 하하 어쨋든 이번에 이러한 토큰 구조에대해 제대로 공부한 느낌이 많이들어 좋았네요. 굿! 그럼 오늘은 여기서 마무리 하겠습니다.
오늘도 화이팅!