참고자료: 견고한 node.js 프로젝트 설계하기
참고자료: 쉽게 알아보는 서버 인증 1편(세션/쿠키, JWT)
참고자료: Express에서 JWT로 인증시스템 구현하기 (Access Token과 Refresh Token)
참고자료: node.js :: Bcrypt로 비밀번호 해싱(Hashing)하기
해당 참고자료들을 참고하여 코드 및 글을 작성했음을 밝힙니다 :)
이번 글에서는 JWT를 활용해 구현한 회원가입/로그인을 정리할 예정!
아마 다음 글에서 토큰 Refresh와 로그아웃 관련한 정리를 할 것 같다.
저번 글에서 Router에 관한 이야기를 했으니, 오늘 글의 순서는
가 될 듯!
JWT_SECRET_ACCESS_KEY=###########
JWT_SECRET_REFRESH_KEY=############
JWT_ALGORITHM=HS256
JWT_ACCESS_EXPIRE=1h
JWT_REFRESH_EXPIRE=14d
우선 .env 파일에 다음과 같이 AccessToken, RefreshToken 발급 시에 사용할 secret key와 알고리즘, 만료 시간을 작성해 주었다!
const jwt = require('jsonwebtoken');
const { User } = require('../models');
require('dotenv').config();
exports.signAccess = (userData) => { //AccessToken 발급
const payload = {
pk:userData.pk,
email:userData.email
}
return jwt.sign(
payload,
process.env.JWT_SECRET_ACCESS_KEY,
{
algorithm:process.env.JWT_ALGORITHM,
expiresIn:process.env.JWT_ACCESS_EXPIRE
}
);
};
exports.signRefresh = (userPk) => { //RefreshToken 발급
return jwt.sign(
{pk:userPk},
process.env.JWT_SECRET_REFRESH_KEY,
{
algorithm:process.env.JWT_ALGORITHM,
expiresIn:process.env.JWT_REFRESH_EXPIRE,
}
);
}
exports.verifyAccess = (accessToken) => { //AccessToken 검증
try {
const verified = jwt.verify(accessToken, process.env.JWT_SECRET_ACCESS_KEY)
return {
success:true,
message:'Token Verified',
userData: {
pk:verified.pk,
email:verified.email
}
}
} catch(error) {
return {
success:false,
message:error.message,
userData:null
}
}
}
exports.verifyRefresh = async (refreshToken) => { //RefreshToken 검증
try {
const verified = jwt.verify(refreshToken, process.env.JWT_SECRET_REFRESH_KEY);
const user = await User.findOne({where:{pk:verified.pk}});
const userToken = user.dataValues.Refresh;
if (userToken === refreshToken) {
return {
success:true,
message:'Token exists and verified',
userPk:verified.pk
};
};
if (userToken !== refreshToken) {
return {
success:false,
message:'Token valid, but not found in DB',
userPk:verified.pk
};
};
} catch (error) {
return {
success:false,
message:'Token not verified',
userPk:null
};
};
};
exports.getAccess = ({authorization}) => {
try {
const accessToken = authorization.split('Bearer ')[1];
return accessToken
} catch(error) {
return error.message;
}
}
exports.getUserWithAccess = async (accessToken) => {
try {
const verified = jwt.verify(accessToken, process.env.JWT_SECRET_ACCESS_KEY)
const user = await User.findOne({where:{pk:verified.pk}})
return user.dataValues
} catch(error) {
return error.message;
}
}
exports.getUserWithRefresh = async (refreshToken) => {
try {
const verified = jwt.verify(refreshToken, process.env.JWT_SECRET_REFRESH_KEY);
const user = await User.findOne({where:{pk:verified.pk}})
return user.dataValues;
} catch(error) {
return error.message;
}
}
RefreshToken에는 원래 사용자 식별 정보를 담지 않았는데, Token 갱신 시에 RefreshToken에서도 사용자 정보를 추출할 수 있어야 할 것 같아 pk값만 넣는 것으로 수정해 둔 상태!
authorization이라는 키에 담겨 Bearer {accessToken}이라는 형태로 전달되는데, 해당 부분에서 accessToken 부분만을 추출하는 함수const authServices = require('../services/authServices');
exports.signUp = async(req, res, next) => {
try {
const context = await authServices.signUp(req.body);
if (context['user']) {
next(); //router에서 다음 -> 로그인 로직으로.
} else {
res.status(409).json({
msg:context['msg']
});
}
} catch (error) {
console.log(error);
res.status(400).json({
msg:context['msg']
});
}
};
exports.logIn = async(req, res, next) => {
try {
const authUser = await authServices.logIn(req.body);
res.status(200).json({
msg:'로그인 성공',
data:authUser
});
} catch(error) {
console.log(error);
next(error);
}
};
견고한 node.js 프로젝트 설계하기 참고자료를 다 이해하진 못했지만..
을 수행하는 느낌으로 코드를 작성해 보았다.
signUp 함수는 회원가입 이후 로그인까지 이어질 수 있도록 작성한 것 외에는..
특별한 부분은 없는 듯!
const bcrypt = require('bcrypt');
const {User} = require('../models');
require('dotenv').config();
const {signAccess, signRefresh, verifyAccess, verifyRefresh, getUserWithRefresh} = require('../utils/auth');
exports.signUp = async ({nickname, email, password}) => {
let context = {'user':null, 'msg':''};
try {
const userExists = await User.findOne({where:{email}}) //없으면 null 반환
if (userExists) {
context['msg'] = '이미 사용 중인 이메일입니다!'
console.log('이미 사용 중인 이메일입니다!');
return context;
} else {
const salt = await bcrypt.genSalt(10);
const hashed_pw = await bcrypt.hash(password, salt);
const user = await User.create({
nickname,
email,
password:hashed_pw
});
context['user'] = user;
return context;
}
} catch(error) {
console.log(error);
context['msg'] = 'catch' + error.message;
return context;
}
};
exports.logIn = async({email, password}) => {
const user = await User.findOne({where:{email}})
const userData = user.dataValues;
const accessToken = signAccess(userData);
const refreshToken = signRefresh(userData.pk);
const tokens = {
access:accessToken,
refresh:refreshToken,
};
user.Refresh = refreshToken;
user.save();
return {userData, tokens};
}
실제 비즈니스 로직에 해당하는 services는 위와 같이 작성해 보았다.
signUp에서는
회원가입 시에 req.body에 담아서 보내는 nickname, email, password 정보를 받아
해당 이메일을 사용 중인 사용자가 있는지를 확인하고, 있다면
회원가입이 정상적으로 되지 않은 상태이기에
{
'user':null,
'msg':'이미 사용 중인 이메일입니다!'
}
라는 context를 반환한다.
반환값을 context라는 object에 담는 방식은 예전에 Django로 백엔드 로직을 작성할 때 본 적이 있는 방식이라 그대로 사용했는데.. 통상적인 방식인지는 모르겠음...
백엔드 내에서만 사용되는 값이라 그냥 나 편한 대로 작성해따..
이메일이 이미 사용 중이지 않다면 bcrypt 라이브러리를 사용해서
salt를 생성하고, 비밀번호를 해싱하는 과정을 거쳐 사용자 정보를 DB에 생성하고, 해당 사용자 정보를 반환해 준다.
bcrypt 라이브러리에 대해서는 참고자료에 잘 정리해 주신 분의 글을 링크해 두어, 참고하면 좋을 듯!
간단히 이야기하면,
Hashing이란
그리고 그 과정에서 사용되는 salt란, 음식에 살짝 소금을 더하듯이
salt라는 임의의 값을 추가해서 Hashing함을 통해, logIn에서는 req.body에 담아서 보내는 사용자의 이메일, 비밀번호를 받아 해당하는 사용자를 찾고, 사용자 정보를 담아 accessToken과 refreshToken을 발급해 준다.
passport 단에서 검증을 마친 유효한 사용자 정보만 들어오기 때문에, login에서 따로 이메일, 비밀번호의 유효성을 검증하지는 않았다.
사용자의 refreshToken은 사용자 정보에 함께 담아 DB에 저장하고,
사용자 정보와, tokens를 다시 controller에 반환해 준다.
#2. 에서 작성했듯, services에서 반환해 준 값을 controller가 받아,
반환값에 따라 적절한 응답을 보내주면 마무리 :)
코드를 작성할 때 분명 여러 번 찾아보았던 내용들이지만, 글로 정리하면서 한 번씩 더 찾아보고, 복습할 수 있게 되는 것 같아 즐겁게 작성 중이다 ㅎㅎ