구글, 카카오, 네이버 로그인 구현을 편하게 해보기 위해 passport 라이브러리를 사용하였다. 그러나 passport 라이브러리는 기본적으로 Serialize, Deserialize를 통해 세션에 유저의 정보를 저장한다. 내가 배포하는 환경은 아주 작은 무료 서버이고, 서버의 메모리를 사용하는 세션을 사용하는 일은 줄이고 싶어서 쿠키를 이용하는 JWT로 바꾸어 보았다. 도중에 passport-jwt를 이용하다가, 안그래도 라이브러리를 많이 사용하고 있는데 라이브러리 의존성이 너무 높아지는 것 같기도 하고, 그래봤자 jwt 전략을 하나 더 만들게 해줄 뿐이라 코드가 더 복잡해지는 느낌이 들어 바닐라로 리프레시 토큰과 엑세스 토큰을 인증하고 재발급하는 미들웨어를 구현했다.
소요 기간 - 1일 Frontend - React Backend - Node.js Express DB - MongoDB |
먼저 passport 라이브러리들을 설치해준다. jwt 토큰을 발급해 쿠키에 저장할 것이기 때문에, cookie-parser도 설치해 주었다.
npm install jsonwebtoken passport passport-local cookie-parser
서버 폴더의 index.js에 passport 사용과 cookie-parser 사용을 등록해 주어야 한다.만일 세션을 사용할 것이라면 express-session도 설치하고, 설정을 해 주어야 한다. 실제 배포 환경때는 secure를 true로 주는 것이 좋다.
const cookieParser = require("cookie-parser");
app.use(cookieParser());
// ===passport===
const passport = require("passport");
const passportConfig = require("./passport");
passportConfig();
// ===express-session===
const session = require("express-session"); //req.session 객체를 생성한다.
app.use(
session({
resave: false,
saveUninitialized: false,
secret: process.env.COOKIE_SECRET,
cookie: {
httpOnly: true,
secure: false,
},
})
);
// ===passport use ===
app.use(passport.initialize()); //req.객체에 passport 설정을 심는다.
// app.use(passport.session()); // req.session 객체에 passport 정보를 저장한다.
.env 파일에 JWT를 생성할 때 사용할 암호화 키를 저장한다.
JWT_SECRET=암호화키
passport 폴더를 생성하고, 로컬 로그인 전략을 구성한다. 로컬 로그인은 sns 로그인 등 외부 api 로그인 과정을 거치지 않고, 직접 사이트에 회원가입을 한 유저가 로그인하는 방법을 말한다. 전체 코드는 아래와 같다.
const passport = require("passport");
const LocalStrategy = require("passport-local").Strategy;
const bcrypt = require("bcryptjs");
const { User } = require("../models/User.js");
module.exports = () => {
passport.use(
new LocalStrategy(
{
usernameField: "id",
passwordField: "password",
},
async (id, password, done) => {
// done은 authError, user, info를 받는다.
try {
// 로그인을 시도한 유저를 찾는다
const exUser = await User.findOne({ id: id });
//유저가 존재할 경우
if (exUser) {
const result = await bcrypt.compare(password, exUser.password);
if (result) {
// 로그인에 성공한 경우
done(null, exUser, { message: "비밀번호가 일치합니다." });
} else {
// 비밀번호가 일치하지 않을 경우
done(null, false, { message: "비밀번호가 일치하지 않습니다." });
}
} else {
// 유저가 존재하지 않을 경우
done(null, false, { message: "존재하지 않는 아이디입니다." });
}
} catch (error) {
console.log(error);
done(error);
}
}
)
);
};
로컬 로그인 전략을 세우기 위해 passport-local 라이브러리를 불러와 import 해 주고, 코드를 작성한다.
const LocalStrategy = require("passport-local").Strategy;
passport 라이브러리는 미들웨어처럼 use를 이용하면 된다. 새로운 로컬 전략을 선언해주고, 필요한 내용들을 채워준다. 이때 오른쪽에 오는 값은 req.body 를 통해 받을 이름이다. req.body.id 와 password 로 보내줄 것이기 때문에 id와 password를 입력해 주었다.
module.exports = () => {
passport.use(
new LocalStrategy(
{
usernameField: "id",
passwordField: "password",
},
이후는 간단하다. req 요청 객체로 받은 아이디와 비밀번호를 db에서 찾고, 존재하지 않으면 존재하지 않는 유저임을, 존재하면 비밀번호를 인증한 뒤 로그인을 성공/실패 처리해주면 된다. 로컬 전략은 done 함수를 통해 세 가지 인수를 반환하는데, 첫 인수는 에러 여부, 두번째 인수는 찾은 유저 정보, 세 번째 인수는 전달할 메세지 등의 객체이다.
async (id, password, done) => {
// done은 authError, user, info를 받는다.
try {
// 로그인을 시도한 유저를 찾는다
const exUser = await User.findOne({ id: id });
//유저가 존재할 경우
if (exUser) {
const result = await bcrypt.compare(password, exUser.password);
if (result) {
// 로그인에 성공한 경우
done(null, exUser, { message: "비밀번호가 일치합니다." });
} else {
// 비밀번호가 일치하지 않을 경우
done(null, false, { message: "비밀번호가 일치하지 않습니다." });
}
} else {
// 유저가 존재하지 않을 경우
done(null, false, { message: "존재하지 않는 아이디입니다." });
}
} catch (error) {
console.log(error);
done(error);
}
이제 이 파일을 passport 폴더의 index.js에서 불러와 주어야 한다. passport 폴더에 index.js를 생성하고, 로컬 전략을 불러온 다음 exports 해 주었다.
const passport = require("passport");
const local = require("./localStrategy");
const { User } = require("../models/User");
module.exports = () => {
local();
};
실제 로그인 과정 시 passport의 로컬 로그인 전략을 이용하기 위해, user.js 라우터에 로그인 라우터를 추가해준다.
// 로그인 라우터
router.post("/login", function (req, res, next) {
// local 이라는 미들웨어를 불러온다.
passport.authenticate("local", (authError, user, info) => {
if (authError) {
console.log("로컬 로그인 에러 : ", authError);
return next(authError);
}
// 로그인을 시도한 유저가 가입하지 않은 상태
if (!user) {
return res.json({
success: false,
message: info.message,
});
}
// 로그인을 시도한 유저가 db에 있을 경우
return req.login(user, { session: false }, (loginError) => {
// 에러가 발생했을 경우(localStrategy.js에서 남)
if (loginError) {
console.log("loginError", loginError);
return next(loginError);
}
// 로그인에 성공했을 경우
// 여기서 JWT 생성
// jwt생성하는 미들웨어 사용
const accessToken = generateToken("accessToken", user._id);
const refreshToken = generateToken("refreshToken", user._id);
user.refreshToken = refreshToken;
// DB에 리프레시 토큰을 저장한다.
user.save((err, user) => {
if (err) {
console.log("토큰 저장 못하고 에러남");
} else {
console.log("토큰 저장함", user);
}
});
// 엑세스 토큰을 쿠키에 저장한다.
// 1000*60=1분
// 1000 * 60 * 30 30분생존
res.cookie("accessToken", accessToken, {
httpOnly: true,
maxAge: 1000 * 60 * 30,
sameSite: "none",
});
// 로그인 유지에 따른 리프레쉬 토큰 저장 방법 구분
if (req.body.maintainLogin) {
// 로그인 유지가 o일 경우 지속 쿠키에 저장한다.
// 7일 생존
res.cookie("refreshToken", refreshToken, {
httpOnly: true,
maxAge: 1000 * 60 * 60 * 24 * 14,
});
} else {
// 만일 로그인 유지가 x이면 세션 쿠키에 저장한다.
res.cookie("refreshToken", refreshToken, { httpOnly: true, sameSite: "none" });
}
return res.status(200).json({
success: true,
user: user,
message: info.message,
});
});
})(req, res, next);
});
먼저 /login 이라는 요청을 받으면, 로그인 라우터는 passport의 authenticate를 통해 "local" 이라고 명시된 전략을 가져와 수행한다. 아까 작성한 localStrategy 를 말한다. 이곳의 (authError, user, info) 에 localStrategy 의 done 함수에서 보낸 것들이 도착한다.
router.post("/login", function (req, res, next) {
// local 이라는 미들웨어를 불러온다.
passport.authenticate("local", (authError, user, info) => {
로그인 에러가 발생했을 경우와, 유저가 가입하지 않았을 경우를 나누어 처리해준다. 에러는 에러를 반환하고, 유저가 없을 경우에는 클라이언트로 json을 반환해 로그인 시도가 실패했음을 알려준다. 이후 return 문을 통해 로그인을 시도한 유저가 db에 있을 경우를 처리하는데, 이 부분이 중요하다.
if (authError) {
console.log("로컬 로그인 에러 : ", authError);
return next(authError);
}
// 로그인을 시도한 유저가 가입하지 않은 상태
if (!user) {
return res.json({
success: false,
message: info.message,
});
}
// 로그인을 시도한 유저가 db에 있을 경우
return req.login(user, { session: false }, (loginError) => {
// 에러가 발생했을 경우(localStrategy.js에서 남)
if (loginError) {
console.log("loginError", loginError);
return next(loginError);
}
아래의 req.login은 Serialize를 호출한다. passport의 Serialize는 로그인 시에만 실행되는 함수인데, 첫 번째 인수로 전달된 user를 받아 세션에 저장하도록 되어 있다. 그러나 나는 세션을 사용하지 않고 JWT를 생성한 후 쿠키에 저장할 것이기 때문에 passport에 '난 세션 이용 안할거니까 Serialize가 필요 없어~' 라고 알려주어야 한다. 이것이 {session:false} 를 두 번째 인수로 작성한 이유이다.
return req.login(user, { session: false }, (loginError) => {
로그인에 성공하면, 미들웨어를 통해 토큰을 생성한다.
// 로그인에 성공했을 경우
// 여기서 JWT 생성
// jwt생성하는 미들웨어 사용
const accessToken = generateToken("accessToken", user._id);
const refreshToken = generateToken("refreshToken", user._id);
generateToken 미들웨어는 midlewares.js 파일에 아래와 같이 작성했다. 엑세스 토큰(accessToken)은 expiresIn을 30m으로 주어 30분짜리 토큰으로 만들고, refreshToken은 14d로 만들어 2주짜리 수명을 가진 토큰으로 생성해 주었다.
// 토큰 생성 미들웨어
exports.generateToken = (type, _id) => {
if (type === "accessToken") {
const accessToken = jwt.sign({ id: _id }, process.env.JWT_SECRET, {
expiresIn: "30m",
});
return accessToken;
} else if (type === "refreshToken") {
const refreshToken = jwt.sign({ id: _id }, process.env.JWT_SECRET, {
expiresIn: "14d",
});
return refreshToken;
}
};
리프레시 토큰은 엑세스 토큰을 재발급 받기 위한 토큰으로 사용되며, 엑세스 토큰은 실제 로그인 유지에 사용되는 토큰이다. 따라서 서버는 해당 유저가 '엑세스 토큰을 가지고 있지 않으나 리프레시 토큰을 가지고 있을 경우에는, 엑세스 토큰을 재발급' 해 줄 수 있어야 한다. 이를 구현하려면 엑세스 토큰으로도 유저를 식별할 수 있어야 하지만, 리프레시 토큰으로도 유저를 식별할 수 있어야 한다. 따라서 리프레시 토큰은 db와 쿠키에 모두 저장하며, 엑세스 토큰은 쿠키에만 저장해 줄 것이다.
user.refreshToken = refreshToken;
// DB에 리프레시 토큰을 저장한다.
user.save((err, user) => {
if (err) {
console.log("토큰 저장 못하고 에러남");
} else {
console.log("토큰 저장함", user);
}
});
// 엑세스 토큰을 쿠키에 저장한다.
// 1000*60=1분
// 1000 * 60 * 30 30분생존
res.cookie("accessToken", accessToken, {
httpOnly: true,
maxAge: 1000 * 60 * 30,
sameSite: "none",
});
엑세스 토큰은 어차피 시한부인 토큰이기 때문에 로그인 유지 유무에 상관 없이 쿠키에 30분짜리 수명을 가진 토큰으로 저장해주면 된다. 그러나 리프레시 토큰일 경우 '로그인을 유지하려면 14d, 아니라면 브라우저 종료 시 삭제되는 세션쿠키로 저장' 해야 하는 옵션이 필요하다. 클라이언트는 로그인 요청을 보낼 때 maintainLogin이라는 값을 true/false 값으로 서버에 보낸다. 만일 이 값이 true라면 로그인을 유지해야 하므로 14일짜리로, 그렇지 않으면 세션 쿠키로 저장한다. 여기서 sameSite: "none", 은 배포를 위한 코드이다. 나는 클라이언트와 백엔드의 주소가 서로 다르기 때문에, 쿠키 옵션의 sameSite를 none으로 주었다. 이렇게 하지 않으면 서로 다른 주소로 배포했을 때 쿠키를 주고받을 수 없다.
// 로그인 유지에 따른 리프레쉬 토큰 저장 방법 구분
if (req.body.maintainLogin) {
// 로그인 유지가 o일 경우 지속 쿠키에 저장한다.
// 14일 생존
res.cookie("refreshToken", refreshToken, {
httpOnly: true,
maxAge: 1000 * 60 * 60 * 24 * 14,
});
} else {
// 만일 로그인 유지가 x이면 세션 쿠키에 저장한다.
res.cookie("refreshToken", refreshToken, { httpOnly: true, sameSite: "none", });
}
또는, 쿠키와 관한 옵션을 라우터에 아래와 같이 설정해 줄 수도 있다.
router.use(function (req, res, next) {
res.header(
"Access-Control-Allow-Origin",
"https://web-boiler-frontend-1jx7m2gld43p7bv.gksl2.cloudtype.app"
);
res.header("Access-Control-Allow-Credentials", true);
res.setHeader("Set-Cookie", "key=value; HttpOnly; SameSite=None");
next();
});
마지막으로 res를 통해 success와 함께 user 정보를 보내 클라이언트에서 user의 정보를 받을 수 있게 해 주었다.
return res.status(200).json({
success: true,
user: user,
message: info.message,
});
이제 생성한 jwt를 인증하되, accessToken이 수명을 다 하면 RefreshToken을 검증하고 재발급 해 주는 과정을 작성하면 된다. auth이라는 이름의 미들웨어로 작성하였다. 먼저 토큰을 재발급 해 주어야 하는 경우는 두 가지이다.
1. accessToken만 만료되었을 경우
2. refreshToken만 만료되었을 경우
두 개의 토큰이 모두 만료되었을 때는 토큰을 재발급하지 않고, 'isLogined'을 false로 반환해 '로그인하지 않은 상태'임을 클라이언트에 알려준다. 둘 다 유효할 때는 로그인이 정상적으로 되어 있는 상황이므로, 토큰을 이용해 유저를 찾아 유저의 정보를 req.user에 담아서 next()를 호출한다.
const { User } = require("../models/User.js");
const jwt = require("jsonwebtoken");
// 유저 인증 미들웨어
let auth = (req, res, next) => {
// 쿠키에서 토큰을 가져온다.
let accessToken = req.cookies.accessToken;
let refreshToken = req.cookies.refreshToken;
// 토큰을 복호화해서 유저를 찾아보는데, 다음 네 가지 경우를 따진다.
// 1. 둘 다 undifined = 에러
// 2. 엑세스만 언디파인드 = 엑세스 재발급
// 3. 리프레쉬만 언디파인드 = 리프레쉬만 재발급
// 4. 둘 다 유효 => next
// 만일 엑세스 토큰이 null 또는 undefined면
if (accessToken === null || accessToken === undefined) {
// refreshToken을 검사해보고, 두 개의 토큰이 모두 사용 불가능일 경우
if (refreshToken === null || refreshToken === undefined) {
return res.json({
isLogined: false,
message: "로그인이 필요합니다.",
});
} else {
// 엑세스 만료 + 리프레시 유효
// refresh토큰을 DB에서 조회한다.
User.findOne({ refreshToken: refreshToken }, (err, user) => {
if (err) {
console.log("리프레시 토큰으로 db에서 찾은정보 에러", err);
}
if (!user) {
console.log("refreshToken으로 찾을 수 있는 유저가 없습니다.");
} else if (user) {
// refresh토큰을 가진 유저가 있으면,
console.log("토큰으로 찾은 유저 정보입니다.", user);
// 엑세스 토큰을 재발급 해준다.
// 엑세스 토큰은 어차피 시간제한임
const accessToken = jwt.sign(
{ _id: user._id },
process.env.JWT_SECRET,
{
expiresIn: "30m",
}
);
res.cookie("accessToken", accessToken, {
httpOnly: true,
maxAge: 1000 * 60 * 30,
sameSite: "none",
});
// 발급했으니 유저 정보 저장하고 넘어감
req.user = user;
next();
}
});
}
} else {
//엑세스 유효
if (refreshToken === null || refreshToken === undefined) {
// 엑세스 유효 + 리프레시 만료
// 엑세스 복호화해서 유저 찾은다음 리프레시 재발급
let _id = jwt.verify(accessToken, process.env.JWT_SECRET);
User.findOne({ _id: _id }, (err, user) => {
if (err) {
console.log("accesstoken으로 찾는 과정에서 에러 ", err);
}
if (!user) {
console.log("엑세스 토큰으로 유저를 찾을 수 없습니다.");
} else if (user) {
//유저 찾았으니 리프레시 재발급(일회성)
const refreshToken = jwt.sign(
{ _id: user._id },
process.env.JWT_SECRET,
{
expiresIn: "14d",
}
);
user.refreshToken = refreshToken;
// DB에 리프레시 토큰을 저장한다.
user.save((err, user) => {
if (err) {
console.log("토큰 저장 못하고 에러남");
} else {
console.log("토큰 저장함", user);
}
});
res.cookie("refreshToken", refreshToken, {
httpOnly: true,
sameSite: "none",
});
// 발급했으니 넘어감..
req.user = user;
next();
}
});
} else {
//엑세스 유효 + 리프레시 유효
User.findOne({ refreshToken: refreshToken }, (err, user) => {
if (err) {
console.log(err);
}
if (!user) {
return res.json({
isLogined: false,
message: "로그인이 필요합니다.",
});
} else if (user) {
// 유저 정보 저장하고 넘어감
req.user = user;
next();
}
});
}
}
};
module.exports = { auth };
auth는 로그인 이후에 작동하는 라우터이므로, /auth로 api 요청을 하면 해당 미들웨어가 작성할 수 있도록 라우터를 만들어준다. auth에서 next를 통해 req.user가 전해져 올 때만 status(200).json의 정보를 클라이언트가 받을 수 있다.
// 로그인 인증 라우터
router.post("/auth", auth, (req, res) => {
res.status(200).json({
isLogined: true,
user: req.user,
});
});
로그아웃은 간단하다. auth에서 리프레시 토큰을 db와 비교하였을 때 , 유저를 찾을 수 없으면 isLogined를 false로 전송해 주었으므로 db에서 리프레시 토큰을 삭제해주면 된다. 나는 겸사겸사 쿠키도 삭제해 주었다. 로그아웃 요청이 들어오면, auth 미들웨어를 통해 현재 클라이언트의 유저 정보를 알아낸 뒤, auth가 보낸 req.user 객체를 통해 db에서 유저를 찾고, 해당 유저의 리프레시 토큰을 지워준다.
router.post("/logout", auth, (req, res) => {
// 리프레시 토큰을 찾아서 삭제해준다.
User.findOneAndUpdate(
{ _id: req.user._id },
{ refreshToken: "" },
(err, doc) => {
if (err) {
return res.json({ success: false, err: err });
} else {
res.cookie("refreshToken", "", { maxAge: 0 });
res.cookie("accessToken", "", { maxAge: 0 });
return res.status(200).send({
success: true,
});
}
}
);
});
클라이언트에서는 /login api를 실행한 뒤, /auth를 매 페이지마다 진행한다. auth를 통해 받은 정보 중 id와 nikname만 리덕스에 저장하고, persist를 통해 세션 스토리지에 동시에 저장하도록 해 주었다.
useEffect(() => {
axios
.post(
`${process.env.REACT_APP_API_USER}/auth`,
{ id: "" },
{
withCredentials: true,
}
)
.then((response) => {
console.log("auth 결과 : ", response);
if (response.data.isLogined) {
setLogined(response.data.isLogined);
dispatch(
GET_USER({
id: response.data.user.id,
nikname: response.data.user.nikname,
})
);
} else if (!response.data.isLogined) {
setLogined(response.data.isLogined);
sessionStorage.clear();
}
});
}, []);
Landing 페이지에서는 아래와 같이 store에서 유저의 아이디와 닉네임을 받아와, 사용했다.
// 로그인 페이지에서 redux에 id와 닉네임 저장해오기 ,,
let store = useSelector((state) => {
return state;
});
let dispatch = useDispatch();
let id = store.user.user_id;
let nikname = store.user.user_nikname;
이제 구글, 카카오, 네이버 로그인 및 유저 정보 수정만 구현하면 보일러 프로젝트는 끝 ! : )
댓글