서버 요청시 중복된 주소를 제거하는
axios Config
baseURL
는 서버 사이드 요청 생성시 사용되는 Prefix URL
이다.
다음과 같이 Saga
에서 서버 요청시 코드의 중복이 발생한다.
// http://localhost:3065/ -> 반복
function signUpAPI(data) {
return axios.post('http://localhost:3065/user', data);
}
function logInAPI(data) {
return axios.post('http://localhost:3065/user/login', data);
}
function logOutAPI(data) {
return axios.post('http://localhost:3065/user/logout', data);
}
그래서 위와 같은 문제를 해결하기 위해 baseURL
을 사용한다.
우선 기존 코드의 중복된 부분을 제거한다.
function signUpAPI(data) {
return axios.post('/user', data);
}
function logInAPI(data) {
return axios.post('/user/login', data);
}
function logOutAPI(data) {
return axios.post('/user/logout', data);
}
sagas/index.js
에서 baseURL
을 사용해 기본 URL을 설정한다.
// sagas/index.js
import { all, fork } from 'redux-saga/effects';
import axios from 'axios'; // axios 불러오기
import userSaga from './user';
import postSaga from './post';
axios.defaults.baseURL = 'http://localhost:3065'; // baseURL설정
export default function* rootSaga() {
yield all([
fork(userSaga),
fork(postSaga),
]);
}
이후 다음과 같이 자동으로 baseURL
이 prefix
로 적용된다.
// axios.post('http://localhost:3065/user/logout');
axios.post('/user/logout');
사용자 인증을 구현해주는
Node.js
의Middleware
Passport
는 "요청 인증"
이라는 단일 목적을 제공하기 위해 설계된 Node.js
인증 Middleware다.
Passport
는 Session
, Cookie
의 처리와 같은 복잡한 작업을 처리하기 위해 검증된 모듈을 사용하는 장점이 있다.
또한 Kakao
, Google
, Naver
...등 다양한 인증 기관의 각각 다른 인증 방법, 구현 방법의 복잡성을 어느정도 통일화한다.
아래 npm명령어를 통해 Passport
를 설치한다.
npm i passport
npm i passport-local
passport/index.js
를 생성한 뒤 Passport
의 기본설정을 한다.
// passprot/index.js
const passport = require('passport');
const local = require('./local'); // local파일 불러오기
module.exports = () => {
passport.serializeUser(() => {
});
passport.deserializeUser(() => {
});
local(); // local파일 실행
};
passport/local.js
를 생성하여 로그인전략을 작성한다.
이 때 Passport
는 응답을 보내지 않고 done
을 이용하여 결과를 판단한다.
done(서버 에러, 성공, 클라이언트 에러)
// passprot/local.js
const passport = require('passport');
const { Strategy: LocalStrategy } = require('passport-local');
const bcrypt = require('bcrypt');
const { User } = require('./models');
module.exports = () => {
passport.use(new LocalStrategy({
// 첫번째 인자 객체는 browser에서 전달받은 req.body 데이터에 대한 설정
usernameField: 'email',
passwordField: 'password',
}, async (email, password, done) => { // 두번째 인자 함수는 req.body 데이터를 이용하여 결과를 판단
try {
const user = await User.findOne({ // DB, req.body의 email 데이터 비교
where: { email } // email(User DB) = email(req.body) 조건의 축약표현
});
if (!user) {
// done(클라이언트 에러)
return done(null, false, { reason: '존재하지 않는 이메일입니다!!' });
}
// DB, req.body의 password 데이터 비교
const result = await bcrypt.compare(password, user.password);
if (result) {
// done(성공)
return done(null, user);
}
// done(클라이언트 에러)
return done(null, false, { reason: '비밀번호가 틀렸습니다.' })
} catch (error) {
// done(서버 에러)
console.error(error);
return done(error);
}
}));
};
app.js
에서 위에서 작성한 Passport
기본 설정파일(index.js
)을 실행한다.
local.js
는index.js
에서 실행
index.js
는app.js
에서 실행
// app.js
const express = require('express');
const passportConfig = require('/passport'); // index.js불러오기
passportConfig(); // index.js실행
app.listen(3065, () => {
console.log('서버 실행 중');
});
작성한 Passport
전략을 관련 Route
에서 실행한다.
예제에서는 routes/user.js
파일에서 User가 Login을 할때 전략을 실행한다.
// routes/user.js
const express = require('express');
const bcrypt = require('bcrypt');
const passport = require('passport'); // passport 불러오기
const { User } = require('../models');
const router = express.Router();
// passport 전략 실행
router.post('/login', (req, res, next) => { // Middleware 확장
passport.authenticate('local', (err, user, info) => { // done(서버에러, 성공, 클라이언트에러)
if (err) { // 서버 에러
console.error(err);
return next(err);
}
if (info) { // 클라이언트 에러
return res.status(401).send(info.reason);
}
return req.login(user, astnc (loginErr) => { // Passport Login
if (loginErr) { // 낮은 확률로 발생하는 Passport Login Error
console.error(loginErr);
return next(loginErr);
}
// 서비스 Login, Passport Login 모두 성공
return res.status(200).json(user);
});
})(req, res, next);
});
module.exports = router;
passprot/local.js
에서 사용한 done(서버에러, 성공, 클라이언트에러)
은 일종의 Callback
으로 전달한 인자는 Passport
전략을 사용하는 Route
로 전달된다.
Cookie
는 사용자의 정보가 웹 서버를 통해 사용자의 컴퓨터에 직접 저장되는 정보의 단위
Session
은 데이터를 서버에 저장하여Cookie
보다 안전하게 많은 데이터를 저장할 수 있는 저장 방식
Browser
에서 요청을 보내고 Backend Server
는 요청에 대한 응답을 직접 전달해야한다.
이 과정에서 보안위협이 있는 데이터를 해커의 위험으로부터 보호하기 위해 의미없는 문자열로 변환하는데 이를 Cookie
라고 한다.
이렇게 Browser
로 전달된 Cookie
는 기존 Backend Server
데이터와 연결되는데 이를 Session
이라고 한다.
Session
에는 기존 데이터를 모두 저장하지 않고, 데이터의 ID만 저장하여 나머지는 Database
와 연결하여 메모리의 부담을 줄인다.
이후 데이터를 사용할때는 Session
에 저장되있는 ID를 이용하여 Database
에서 필요한 데이터를 검색해 사용한다.
아래 npm명령어를 통해 관련 패키지를 설치한다.
npm i cookie-parser
npm i express-session
app.js
에서 관련 Middleware
를 추가한다.
// app.js
const express = require('express');
const passport = require('passport'); // passport 불러오기
const session = require('express-session'); // session 불러오기
const cookieParser = require('cookie-parser'); // cookie 불러오기
// middleware 추가
app.use(cookieParser('recipeIosecert'));
app.use(session({
saveUninitialized: false,
resave: false,
secret: 'recipeIosecert',
}));
app.use(passport.initialize());
app.use(passport.session());
app.listen(3065, () => {
console.log('서버 실행 중');
});
위 예제 Passport
에서 로그인을 수행하면 내부적으로 데이터를 Cookie
로 변환한 뒤 Session
과 연결한다.
// routes/user.js
:
:
return req.login(user, astnc (loginErr) => {
if (loginErr) {
console.error(loginErr);
return next(loginErr);
}
// res.setHeader('Cookie', '의미없는 문자열')
return res.status(200).json(user);
});
})(req, res, next);
});
module.exports = router;
Browser
에서는 Cookie
를 사용하여 Backend Server
에 리소스를 요청한다.
하지만 이전 포스팅에서 설명했듯 Browser
는 다른 도메인 요청시 이를 차단하여 Cors Error
를 발생시키기 때문에 Cookie
또한 전달할 수 없다.
그래서 이러한 문제를 해결하기 위해 Cors 모듈
의 withCredentials
옵션을 사용한다.
withCredentials
옵션을 사용하기 위해서는 다음과 같이 클라이언트, 서버 모두 설정해야 한다.
// front/sagas/post.js
function addCommentAPI(data) {
return axios.post(`/comment/${data.postId}/`, data, {
withCredentials: true,
})
}
function addPostAPI(data) {
return axios.post(`/post/${data.postId}/`, data, {
withCredentials: true,
})
}
// 코드 중복 제거
// front/sagas/index.js
axios.defaults.withCredentials = true;
// back/app.js
app.use(cors({
// credential mode를 설정하면 보안상의 이유로 origin의 주소를 명시적으로 설정
origin: 'http://localhost:3060',
// credential: false, 기본값은 false
// true로 설정하면 Cookie도 함께 전달
credential: true,
}))
Passport login
실행과 동시에 로그인 정보가 Passport SerializeUser
로 전달되어 Cookie
와 연결되어 Session
에 저장될 ID를 지정한다.
최초 로그인이후에는 Router
접근 시 DeserializeUser
가 실행되어 Session
에 저장된 ID를 이용해 Database
에서 검색하여 사용한다.
검색된 User정보는 req.user
라는 request
속성으로 사용할 수 있다.
// passprot/index.js
const passport = require('passport');
const local = require('./local');
const { User } = require('../models');
module.exports = () => {
passport.serializeUser((user, done) => {
// session의 메모리 절약을 위해 user.id만 저장
done(null, user.id);
});
passport.deserializeUser(async (id, done) => {
try {
// 재로그인시 user.id를 통해 DB에서 복원
const user = await User.findOne({ where: { id }});
done(null, user);
} catch (error) {
console.error(error);
done(error);
}
});
local(); // local파일 실행
};
// routes/post.js
router.post('/:postId/comment', isLoggedIn, async (req, res, next) => {
try {
const comment = await Comment.create({
content: req.body.content,
PostId: req.params.postId,
UserId: req.user.id, // req.user = Login User의 정보
})
res.status(201).json(comment);
} catch (error) {
console.error(error);
next(error);
}
});
Dotenv
는.env
파일에서process.env
로 환경 변수를 Load하는 제로 종속성 모듈
해커들은 Secret
의 Key
를 통해 Cookie
를 복원하여 기존 데이터를 해킹한다.
// app.js
app.use(passport.sesstion({
saveUninitialized: false,
resave: false,
secret: 'Cookie Secret Key 입력', // Secret의 Key는 보안위협
}));
app.use(cookieParser('Cookie Secret Key 입력')); // Secret의 Key는 보안위협
Dotenv
는 이러한 Secret
을 개별 관리하여 해커들로부터 보호하기 위해 사용되는 라이브러리이다.
Dotenv
는 흩어진 비밀키를 한곳에서 관리하기 때문에 해킹시 소스 코드만 유출되어 타격이 감소한다.
아래 npm명령어를 통해 Dotenv
를 설치한다.
npm i dotenv
.env
파일을 생성하여 secret
을 개별 관리한다.
.env
파일은 보안의 중심이기 때문에 git
, github
에서도 공유하지 않고, 핵심 관리자들끼리만 소유하여 프로젝트 실행시에만 사용한다.
// .env
COOKIE_SECRET=Secret Key
DB_PASSWORD=DB의 비밀번호
Middleware
를 관리하는 app.js
를 아래와 같이 수정한다.
// app.js
const dotenv = require('dotenv');
dotenv.config();
app.use(passport.sesstion({
saveUninitialized: false,
resave: false,
// .env 파일의 COOKIE_SECRET으로 치환되어 적용
secret: process.env.COOKIE_SECRET,
}));
app.use(cookieParser(process.env.COOKIE_SECRET));
Dotenv
는 .json
확장자를 사용할 수 없기때문에 config.json
파일의 확장자를 .js
로 변경후 아래와 같이 코드를 수정한다.
// config.js
const dotenv = require('dotenv');
dotenv.config();
module.exports = {
"development": {
"username": "root",
"password": process.env.DB_PASSWORD,
"database": "recipe.io",
"host": "127.0.0.1",
"dialect": "mysql"
},
"test": {
"username": "root",
"password": process.env.DB_PASSWORD,
"database": "recipe.io",
"host": "127.0.0.1",
"dialect": "mysql"
},
"production": {
"username": "root",
"password": process.env.DB_PASSWORD,
"database": "recipe.io",
"host": "127.0.0.1",
"dialect": "mysql"
}
}
Passport Login의 코드 흐름은 다음과 같다.
Login form
에서 데이터를 Saga
로 전달// value: {email: 아이디, password: 비밀번호, }
const onSubmitForm = useCallback((value) => {
dispatch(loginRequestAction(value));
}, []);
Saga
에서 Backend Server
로 데이터를 전달 및 요청function logInAPI(data) {
axios.post('http://localhost:3065/user/login', data);
}
Router
에서 Passport
전략 실행// routes/user.js
router.post('/login', (req, res, next) => {
// passport.authenticate('local',... 부분에서 passport전략 실행
passport.authenticate('local', (err, user, info) => {
:
:
:
Passport
전략에서 결과 판단 후 Router
로 전달// passprot/local.js
const passport = require('passport');
const { Strategy: LocalStrategy } = require('passport-local');
const bcrypt = require('bcrypt');
const { User } = require('./models');
module.exports = () => {
passport.use(new LocalStrategy({
usernameField: 'email',
passwordField: 'password',
}, async (email, password, done) =>
try {
const user = await User.findOne({
where: { email }
});
if (!user) {
return done(null, false, { reason: '존재하지 않는 이메일입니다!!' });
}
const result = await bcrypt.compare(password, user.password);
if (result) {
return done(null, user); // email, password 모두 존재하므로 결과를 route의 콜백으로 전달
}
return done(null, false, { reason: '비밀번호가 틀렸습니다.' })
} catch (error) {
console.error(error);
return done(error);
}
}));
};
Passport
로그인 시도 및 SerializeUser
실행// routes/user.js
const express = require('express');
const bcrypt = require('bcrypt');
const passport = require('passport');
const { User } = require('../models');
const router = express.Router();
router.post('/login', (req, res, next) =>
// passport 전략에서 done으로 판단한 결과를 콜백으로 전달 -> done(null, user);
passport.authenticate('local', (err, user, info) =>
if (err) {
console.error(err);
return next(err);
}
if (info) {
return res.status(401).send(info.reason);
}
return req.login(user, astnc (loginErr) => { // 에러가 발생하지 않았다면 Passport Login시도
if (loginErr) {
console.error(loginErr);
return next(loginErr);
}
return res.status(200).json(user);
});
})(req, res, next);
});
module.exports = router;
// passprot/index.js
const passport = require('passport');
const local = require('./local');
const { User } = require('../models');
module.exports = () => {
// cookie, user.id만 서버에 저장
passport.serializeUser((user, done) => {
done(null, user.id);
});
passport.deserializeUser(async (id, done) => {
try {
// 재로그인시 user.id를 통해 DB에서 복원
const user = await User.findOne({ where: { id }});
done(null, user);
} catch (error) {
console.error(error);
done(error);
}
});
local(); // local파일 실행
};
Front Server
로 데이터 전달// routes/user.js
router.post('/login', (req, res, next) =>
passport.authenticate('local', (err, user, info) =>
:
:
return res.status(200).json(user); // cookie, 사용자정보를 Front Server로 전달
DeserializeUser
실행// passprot/index.js
passport.deserializeUser(async (id, done) => {
try {
// 재로그인시 user.id를 통해 DB에서 복원
const user = await User.findOne({ where: { id }});
done(null, user); // req.user에 복원된 사용자정보를 전달
} catch (error) {
console.error(error);
done(error);
}
});
Passport Logout
의 과정은 Login
에 비해 간략하다.
다음과 같이 Request.User
의 데이터를 삭제한 뒤, Session
을 삭제하면 Logout
을 구현할 수 있다.
// routes/user.js
router.post('/logout', (req, res) => {
// request.user 데이터 삭제
req.logout(() => {
res.redirect('/');
});
req.session.destroy(); // session 삭제
res.send('ok');
});
Client Side Rendering
을 사용하여Login
정보를 유지
Login
을 하면 Browser
에서는 다음과 같이 Cookie
를 전달받아 서버의 Login
정보를 확인할 수 있다.
하지만 서버가 재시작되면 Cookie
가 Backend Server
로 전달되지 않아, 즉 서버의 Session
이 사라져 Cookie
와 ID
를 연결한 메모리가 없기 때문에 Login
이 풀리게 된다.
이러한 문제를 해결하기 위해서는 앞선 포스팅에서 소개한 CSR
을 사용하여 서버 재시작시 Login Cookie
를 전달해야 한다.
Login
페이지에서 서버 재시작시 Action
을 Dispatch
// pages/index.js
useEffect(() => {
dispatch({
type: LOAD_MY_INFO_REQUEST
});
}, []);
Login
페이지에서 실행할 Action
을 Reducer
에서 작성
// reducers/user.js
const reducer = (state = initialState, action) => {
return produce(state, (draft) => {
switch (action.type) {
case LOAD_MY_INFO_REQUEST:
draft.loadMyInfoLoading = true;
draft.loadMyInfoDone = false;
draft.loadMyInfoError = null;
break;
case LOAD_MY_INFO_SUCCESS:
draft.loadMyInfoLoading = false;
draft.loadMyInfoDone = true;
draft.me = action.data;
break;
case LOAD_MY_INFO_FAILURE:
draft.loadMyInfoLoading = false;
draft.loadMyInfoError = action.error;
break;
Saga
에서 서버로 Login 정보를 요청
function loadMyInfoAPI() {
return axios.get('/user');
}
function* loadMyInfo() {
try {
const result = yield call(loadMyInfoAPI, action.data);
yield put({
type: LOAD_MY_INFO_SUCCESS,
data: result.data,
})
} catch(err) {
yield put({
type: LOAD_MY_INFO_FAILURE,
error: err.response.data
})
}
}
Router
를 작성하여 Saga
에서 전달받은 요청을 응답
// routes/user.js
router.get('/', async (req, res, next) => {
try {
if (req.user) { // User가 Login한 경우 User의 정보를 전달
const fullUserWithoutPassword = await User.findOne({
where: { id: req.user.id },
attributes: { exclude: ['password'] },
include: [{
model: Post,
}]
})
res.status(200).json(fullUserWithoutPassword);
} else { // User가 Login하지 않은 경우 Null 전달
res.status(200).json(null);
}
} catch (error) {
console.error(error);
next(error);
}
});
결과를 확인해보면 다음과 같이 서버 재시작시 로그인정보가 유지되는것을 확인할 수 있다.
Node.js 공식문서
Node.js 교과서 - 조현영
React로 NodeBird SNS 만들기 - 제로초