(Node.js) Passport Login

Mirrer·2022년 9월 8일
0

Node.js

목록 보기
6/12
post-thumbnail

baseURL

서버 요청시 중복된 주소를 제거하는 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),    
  ]);
}

이후 다음과 같이 자동으로 baseURLprefix로 적용된다.

// axios.post('http://localhost:3065/user/logout');
axios.post('/user/logout');

Passport

사용자 인증을 구현해주는 Node.jsMiddleware

Passport"요청 인증"이라는 단일 목적을 제공하기 위해 설계된 Node.js 인증 Middleware다.

PassportSession, Cookie의 처리와 같은 복잡한 작업을 처리하기 위해 검증된 모듈을 사용하는 장점이 있다.

또한 Kakao, Google, Naver...등 다양한 인증 기관의 각각 다른 인증 방법, 구현 방법의 복잡성을 어느정도 통일화한다.


사용 방법

Passport 설치

아래 npm명령어를 통해 Passport를 설치한다.

npm i passport
npm i  passport-local

Passport 기본설정

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);
    }
  }));
};

Passport 기본설정 등록

app.js에서 위에서 작성한 Passport 기본 설정파일(index.js)을 실행한다.

local.jsindex.js에서 실행
index.jsapp.js에서 실행

// app.js
const express = require('express');
const passportConfig = require('/passport'); // index.js불러오기

passportConfig(); // index.js실행

app.listen(3065, () => {
  console.log('서버 실행 중');
});

Passport전략 실행

작성한 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는 사용자의 정보가 웹 서버를 통해 사용자의 컴퓨터에 직접 저장되는 정보의 단위
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

Middleware 추가

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;

withCredentials

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 SerializeUser, DeserializeUser

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

Dotenv.env 파일에서 process.env로 환경 변수를 Load하는 제로 종속성 모듈

해커들은 SecretKey를 통해 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는 흩어진 비밀키를 한곳에서 관리하기 때문에 해킹시 소스 코드만 유출되어 타격이 감소한다.


사용 방법

Dotenv 설치

아래 npm명령어를 통해 Dotenv를 설치한다.

npm i dotenv

Secret 관리

.env 파일을 생성하여 secret을 개별 관리한다.

.env파일은 보안의 중심이기 때문에 git, github에서도 공유하지 않고, 핵심 관리자들끼리만 소유하여 프로젝트 실행시에만 사용한다.

// .env
COOKIE_SECRET=Secret Key
DB_PASSWORD=DB의 비밀번호

Middleware 추가

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));

Config.json 수정

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 Procedure

Passport Login의 코드 흐름은 다음과 같다.


  1. Login form에서 데이터를 Saga로 전달
// value: {email: 아이디, password: 비밀번호, }
const onSubmitForm = useCallback((value) => {
    dispatch(loginRequestAction(value));    
  }, []);

  1. Saga에서 Backend Server로 데이터를 전달 및 요청
function logInAPI(data) {
  axios.post('http://localhost:3065/user/login', data);
}

  1. Router에서 Passport전략 실행
// routes/user.js
router.post('/login', (req, res, next) => {
  //  passport.authenticate('local',... 부분에서 passport전략 실행
    passport.authenticate('local', (err, user, info) => {
                                        :
                                        :
                                        :

  1. 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);
    }
  }));
};

  1. 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파일 실행
};

  1. 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로 전달

  1. 최초 로그인 후 재요청시 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

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

Client Side Rendering을 사용하여 Login 정보를 유지

Login을 하면 Browser에서는 다음과 같이 Cookie를 전달받아 서버의 Login 정보를 확인할 수 있다.

하지만 서버가 재시작되면 CookieBackend Server로 전달되지 않아, 즉 서버의 Session이 사라져 CookieID를 연결한 메모리가 없기 때문에 Login이 풀리게 된다.

이러한 문제를 해결하기 위해서는 앞선 포스팅에서 소개한 CSR 을 사용하여 서버 재시작시 Login Cookie를 전달해야 한다.


CSR Login 구현

Login 페이지에서 서버 재시작시 ActionDispatch

// pages/index.js
useEffect(() => {
    dispatch({
        type: LOAD_MY_INFO_REQUEST
    });
}, []);

Login 페이지에서 실행할 ActionReducer에서 작성

// 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 만들기 - 제로초

profile
memories Of A front-end web developer

0개의 댓글