3계층구조에 Access/Refresh Token 적용하기!
✅코드방향잡기
- 로그인 시 Access/Refresh Token 를 생성하고 사용자 인증미들웨어에도 적용할 예정이다.
- Access Token과 다르게 Refresh Token은 저장될 DB가 필요하고, DB에 저장하는 함수도 생성해야한다.
- 토큰을 발급받고 cookie에 담는 과정을 3계층 어디에 적용할 것인지 생각해보자.
- 토큰발급-Service, 토큰담기-Controller, Refresh토큰DB에저장하기-Repository
1. RToken을 저장해줄 table을 만들어준다!
migration파일
'use strict';
/** @type {import('sequelize-cli').Migration} */
module.exports = {
async up(queryInterface, Sequelize) {
await queryInterface.createTable('Tokens', {
token_id: {
allowNull: false,
autoIncrement: true,
primaryKey: true,
type: Sequelize.INTEGER,
},
user_id: {
allowNull: false,
type: Sequelize.INTEGER,
},
token: {
allowNull: false,
type: Sequelize.TEXT,
},
createdAt: {
allowNull: false,
defaultValue: Sequelize.fn('now'),
type: Sequelize.DATE,
},
updatedAt: {
allowNull: false,
defaultValue: Sequelize.fn('now'),
type: Sequelize.DATE,
},
});
},
async down(queryInterface, Sequelize) {
await queryInterface.dropTable('Tokens');
},
};
model파일
'use strict';
const { Model } = require('sequelize');
module.exports = (sequelize, DataTypes) => {
class Tokens extends Model {
/**
* Helper method for defining associations.
* This method is not a part of Sequelize lifecycle.
* The `models/index` file will call this method automatically.
*/
static associate(models) {
// define association here
}
}
Tokens.init(
{
token_id: {
allowNull: false,
autoIncrement: true,
primaryKey: true,
type: DataTypes.INTEGER,
},
user_id: {
allowNull: false,
type: DataTypes.INTEGER,
},
token: {
allowNull: false,
type: DataTypes.TEXT,
},
createdAt: {
allowNull: false,
defaultValue: DataTypes.NOW,
type: DataTypes.DATE,
},
updatedAt: {
allowNull: false,
defaultValue: DataTypes.NOW,
type: DataTypes.DATE,
},
},
{
sequelize,
modelName: 'Tokens',
}
);
return Tokens;
};
2. 로그인 controller 작성
//로그인controller
login = async (req, res) => {
const { email, password } = req.body;
const user = await this.userService.findOneUser(email);
try {
if (!user || password !== user.password) {
res.status(412).json({
errorMessage: '닉네임 또는 패스워드를 확인해주세요.',
});
return;
}
로그인시 발급받은 토큰을 어디에 어떻게 담을지 결정해줄 부분
} catch (err) {
console.error(err);
res.status(400).json({
errorMessage: '로그인에 실패하였습니다.',
});
}
};
3. 로그인 service 작성
로그인시 email을 통해 가입정보가 있는지 확인하고, 있다면 A/R Token을 발행해준다.
여기서 생성된 R Token은 DB에 저장되야 하므로 Repo를 작성해보자.
login = async (email) => {
const user = await this.userRepository.findOneUser(email);
const userId = user.user_id;
const accessToken = jwt.sign({ user_id: user.user_id }, 'secret', {
expiresIn: '10s',
});
const accessObject = { type: 'Bearer', token: accessToken };
const refreshToken = jwt.sign({ user_id: user.user_id }, 'secret', {
expiresIn: '7d',
});
//생성한 refresh토큰을 repo에서 저장한다.
await this.tokenRepository.setRefreshToken(refreshToken, userId);
return { accessObject, refreshToken };
};
4. RToken을 저장하고 반환해줄 repo작성
const jwt = require('jsonwebtoken');
const { Tokens } = require('../models');
class TokenRepository {
setRefreshToken = async (refreshToken, userId) => {
const rToken = await Tokens.create({
user_id: userId,
token: refreshToken,
});
return rToken;
// }
};
module.exports = TokenRepository;
5. tokens를 불러오는 코드까지 작성하면 완성된 login Controller
login = async (req, res) => {
const { email, password } = req.body;
const user = await this.userService.findOneUser(email);
try {
if (!user || password !== user.password) {
res.status(412).json({
errorMessage: '닉네임 또는 패스워드를 확인해주세요.',
});
return;
}
//userData는 accessObject, refreshToken
const userData = await this.userService.login(email);
//Bearer, token 따로 따로 지정해줌
res.cookie(
'Authorization',
`${userData.accessObject.type} ${userData.accessObject.token}`
);
res.cookie('refreshtoken', userData.refreshToken);
res
.status(200)
.json({
message: '로그인에 성공하였습니다.',
Authorization: `${userData.accessObject.type} ${userData.accessObject.token}`,
refreshtoken: userData.refreshToken,
});
} catch (err) {
console.error(err);
res.status(400).json({
errorMessage: '로그인에 실패하였습니다.',
});
}
};
6. 사용자인증 미들웨어
이 부분이 좀 어려웠는데, 목적과 검증절차를 잘 생각해보면 쉽다.
- authmiddleware의 목적은 결국 인증된 user정보를 res.locals.user에 담아주기 위함이다.
- 그럴려면 cookie로 전달받은 tokens가 유효한지 검증하고, 유효하지 않으면 재발급/삭제 해야한다.
- AToken은 계속 재발급되야하기에 middleware에 재발급 함수를 구현한다
-
1. cookie에서 A/RToken을 불러온다.
2. jwt.verify를 통해 유효성을 검증하여 true or false값을 도출한다.
3. RToken이 false일때 db에서 삭제해준다.
4. AToken이 false일때 db에서 userId를 기반으로 RToken을 찾아서 생성해준다.
이때 AToken을 생성해줄 function이 필요하다.
또한 AToken을 재발급해줄 RToken이 db에 없다면 다시 RToken을 생성한다.
5. A/RToken이 모두 정상적으로 존재한다면 res.locals.user로 정보를 넘겨준다.
const jwt = require('jsonwebtoken');
const { Users } = require('../models');
const { Tokens } = require('../models');
const TokenRepository = require('../repositories/tokens.repository');
module.exports = async (req, res, next) => {
const tokenRepository = new TokenRepository(Tokens);
let { Authorization, refreshtoken } = req.headers;
//객체 형태로 가지고 올꺼야
//1. header와 쿠키에서 access, refresh 추출
try {
Authorization = !req.headers.refreshtoken
? req.cookies.Authorization
: Authorization;
refreshtoken = !req.headers.refreshtoken
? req.cookies.refreshtoken
: refreshtoken;
console.log(req.cookies);
//Authorization 은 Bearer형식으로 전달되어왔기 때문에 split으로 분리해준다.
const [authType, accessToken] = (Authorization ?? '').split(' ');
const isAccessTokenValidate = validateAccessToken(accessToken);
const isRefreshTokenValidate = validateRefreshToken(refreshtoken);
//2.refresh 유효성 검증값이 false일때, db에 refreshtoken 지워줌
//(!isRefreshTokenValidate) means !true
if (!isRefreshTokenValidate) {
await tokenRepository.deleteRefreshToken2(refreshtoken);
return res.status(419).json({
message: 'Refresh Token이 만료되었습니다. 다시 로그인 해주세요',
});
}
//access token의 유효성 검증값이 false일때 access를 다시 생성해야함
if (!isAccessTokenValidate) {
//서버가 발급한 refresh가 맞는지 확인 후 해당토큰에서 추출한 user_id가져와서 userId에 할당
const userId = jwt.verify(refreshtoken, 'secret').user_id;
//그 userId로 해당 사용자의 refreshtoken가져옴
const userR = await tokenRepository.getRefreshToken(userId);
if (!userR) {
return res.status(419).json({
message:
'Refresh Token의 정보가 서버에 존재하지 않습니다. 다시 로그인 해주세요',
});
}
//refresh 를 찾아와서 access 재발급
const newAccessToken = createAccessToken(userR);
res.cookie('Authorization', `Bearer ${newAccessToken}`);
//새로운 access토큰의 userId를 사용하여 데이터 베이스에서 사용자 정보 가져온 후 넘겨준다
const user = await Users.findOne({ where: { user_id: userId } });
res.locals.user = user;
}
next();
} catch (err) {
console.log(err);
res.clearCookie('Authorization');
return res.status(403).send({
errorMessage:
'전달된 쿠키에서 오류가 발생하였습니다. 다시 로그인 해주세요',
});
}
};
function createAccessToken(user) {
const accessToken = jwt.sign({ user_id: user.user_id }, 'secret', {
expiresIn: '10s',
});
return accessToken;
}
//1. refresh, access Token의 유효성 검사(만료or위조 되었는지)
//jwt.verify를 통해서 진행한다.
function validateAccessToken(accessToken) {
try {
jwt.verify(accessToken, 'secret');
return true;
} catch (error) {
return false;
}
}
function validateRefreshToken(refreshToken) {
try {
jwt.verify(refreshToken, 'secret');
return true;
} catch (error) {
return false;
}
}
7. 위의 과정에서 저장되어있는 refresh Token이 유효하지않을때 delete해야하고, access Token을 재발급해야할땐 refresh Token을 get 해야 하기 때문에 Tokens Repo에 delete, get 또한 추가해준다.
const jwt = require('jsonwebtoken');
const { Tokens } = require('../models');
class TokenRepository {
//refreshToken 이 있는지 확인, 없으면 생성 후 rToken 반환
setRefreshToken = async (refreshToken, userId) => {
// const existRefreshToken = await Tokens.findOne({
// where: { user_id: userId },
// attributes: ['user_id'],
// });
// if (!existRefreshToken) {
const rToken = await Tokens.create({
user_id: userId,
token: refreshToken,
});
console.log(rToken);
return rToken;
// }
};
//refreshToken을 찾아서 반환
getRefreshToken = async (userId) => {
const token = await Tokens.findOne({
where: { user_id: userId },
attributes: ['user_id'],
});
return token;
};
// deleteRefreshToken = async (user_id) => {
// await Tokens.destroy({
// where: { user_id },
// });
// };
//db에 있는 refreshToken을 삭제
deleteRefreshToken2 = async (refreshToken) => {
await Tokens.destroy({
where: { token: refreshToken },
});
};
}
module.exports = TokenRepository;