스터디 포스트 >  웹 개발 실전 프로젝트

Yelp Camp 활용한 프로젝트

조재홍 멘토
이로운 개발자가 되고 싶은 조재홍입니다.

웹 개발 실전 프로젝트 - 5주차

 
💡
Yelp Camp 활용한 프로젝트
 

462. 리뷰 모델 정의하기

  • 리뷰 모델 정의
const mongoose = require('mongoose');
const Schema = mongoose.Schema;

const ReviewSchema = new Schema({
  body: String,
  rating: Number,
});

module.exports = mongoose.model('Review', ReviewSchema);
models/review.js
  • one to many 관계 - 리뷰들을 캠핑장 하나에 연결하기
    • CampgroundSchema 업데이트
const CampgroundSchema = new Schema({
  title: String,
  image: String,
  price: Number,
  description: String,
  location: String,
  reviews: [
    {
      type: Schema.Types.ObjectId,
      ref: 'Review',
    },
  ],
});
models/campground.js
 

463. 리뷰 형식 추가하기

notion image
 

464. 리뷰 작성하기

  • 라우트 생성 POST /campgrounds/:id/reviews
const Review = require('./models/review');

app.post('/campgrounds/:id/reviews', catchAsync(async (req, res) => {
    const campground = await Campground.findById(req.params.id);
    const review = new Review(req.body.review);
    campground.reviews.push(review);
    await review.save();
    await campground.save();
    res.redirect(`/campgrounds/${campground._id}`);
  })
);
  • 리뷰를 작성해보면 성공적으로 저장된다.
notion image
 

465. 리뷰 검증하기

  • 부트스트랩 유효성 검사 추가하기
  • form에 novalidate , textarea에 required , class로 validated-form 을 추가해서 클라이언트 측 유효성 검사
  • Postman, AJAX 등으로 유효성 검사를 피하는 경우를 막기 위해 Joi 사용
module.exports.reviewSchema = Joi.object({
  review: Joi.object({
    rating: Joi.number().required().min(1).max(5),
    body: Joi.string().required(),
  }).required(),
});
schemas.js
const { campgroundSchema, reviewSchema } = require('./schemas');
// ...
const validateReview = (req, res, next) => {
  const { error } = reviewSchema.validate(req.body);
  if (error) {
    const msg = error.details.map((el) => el.message).join(',');
    throw new ExpressError(msg, 400);
  } else {
    next();
  }
};
// ...
app.js
  • 미들웨어를 인자로 전달하면 된다.
app.post('/campgrounds/:id/reviews', validateReview, catchAsync(async (req, res) => { ... });
 

466. 리뷰 표시하기

  • 캠프장 show 페이지의 라우트에 리뷰 데이터 populate 하기
app.get('/campgrounds/:id', catchAsync(async (req, res) => {
    const campground = await Campground.findById(req.params.id).populate('reviews');
    res.render('campgrounds/show', { campground });
  })
);
<% for(let review of campground.reviews) { %>
<div class="card mb-3 ">
    <div class="card-body">
        <h5 class="card-title">Rating: <%= review.rating %></h5>
        <p class="card-text">Review: <%= review.body %></p>
        <button class="btn btn-sm btn-danger">Delete</button>
    </div>
</div>
<% } %>
views/campgrounds/show.ejs
  • 리뷰를 db에서 전부 삭제하려면 db.reviews.deleteMany({})
 

467. 리뷰 스타일링

notion image

468. 리뷰 삭제하기

<form action="/campgrounds/<%=campground._id%>/reviews/<%=review._id%>?_method=DELETE" method="POST">
    <button class="btn btn-sm btn-danger">Delete</button>
</form>
views/campgrounds/show.ejs
app.delete('/campgrounds/:id/reviews/:reviewId', catchAsync(async (req, res) => {
    const { id, reviewId } = req.params;
    await Campground.findByIdAndUpdate(id, { $pull: { reviews: reviewId } });
    await Review.findByIdAndDelete(reviewId);
    res.redirect(`/campgrounds/${id}`);
  })
);
  • $pull 연산자 - Mongo에서 사용하는 배열 수정 연산자
    • 배열에 있는 모든 인스턴스 중에 특정 조건에 만족하는 값을 지운다.
 

469. 캠프그라운드의 미들웨어 삭제

  • 모델에 findOneAndDelete 쿼리 미들웨어 추가하기 (post 사용)
    • 쿼리 미들웨어는 문서를 전달 (여기에선 삭제된 문서)
    • 리뷰 배열에서 삭제된 문서 ID 필드를 가진 모든 리뷰들을 삭제하는 함수 작성
    • 471. Express 라우터 개요

    • 라우터 객체는 미들웨어와 라우트의 인스턴스
    • 라우터를 하나의 작은 앱으로 보기도 한다.
    • Express 라우터를 사용해 라우트를 파일로 옮기고 분류할 수 있다.
      • const express = require('express');
        const router = express.Router();
        
        router.get('/', (req, res) => {
          res.send('ALL SHELTERS');
        });
        router.post('/', (req, res) => {
          res.send('CREATING SHELTER');
        });
        router.get('/:id', (req, res) => {
          res.send('VIEWING ONE SHELTER');
        });
        router.get('/:id/edit', (req, res) => {
          res.send('EDITING ONE SHELTER');
        });
        
        module.exports = router;
        routes/shelters.js
      notion image
      notion image
    • app.use의 첫 번째 인자는, 라우터에서 미리 정의한 라우트의 접두사를 지정한 것
    •  

      472. Express 라우터와 미들웨어

      const express = require('express');
      const router = express.Router();
      
      router.use((req, res, next) => {
        if (req.query.isAdmin) {
          return next();
        }
        return res.send('SORRY NOT AN ADMIN!');
      });
      
      router.get('/topsecret', (req, res) => {
        res.send('THIS IS TOP SECRET');
      });
      router.get('/deleteeverything', (req, res) => {
        res.send('OK DELETED IT ALL!');
      });
      
      module.exports = router;
      routes/admin.js
    • 미들웨어를 라우터 안에 넣으면 해당 라우터 안의 라우트에만 적용되어 유용하게 쓸 수 있다.
    •  

      473. 쿠키 개요

    • HTTP 쿠키
      • 사용자의 브라우저에 저장할 수 있는 작은 정보 조각으로, 특정 웹 페이지에 연결되어 있다.
      • 쿠키는 한 쌍의 키-값이다.
      • 웹사이트에 들어온 다음, 요청에 쿠키 정보를 포함하게 된다.
      • Personalization - 일부 사용자에 대한 정보를 기억하고, 시간이 흐른 뒤에도 사용자에게 관련 컨텐츠를 보여줄 수 있다.
      • 추적 기능
      • 크롬 개발자 도구 - Application 탭에서 확인 가능
       

      474. 쿠키 보내기

    • Express로 쿠키 설정
    • app.get('/setname', (req, res) => {
          res.cookie('name', 'henrietta');
          res.cookie('animal', 'harlequin shrimp')
          res.send('OK SENT YOU A COOKIE!!!')
      })
      notion image
       

      475. 쿠키 파서 미들웨어

    • 들어온 요청에서 쿠키를 파싱해 회수하기
    • npm i cookie-parser 패키지 설치
    • const cookieParser = require('cookie-parser');
      app.use(cookieParser());
      
      app.get('/greet', (req, res) => {
          const { name = 'No-name' } = req.cookies;
          res.send(`Hey there, ${name}`)
      })
      notion image
    • 중요한 정보를 저장할 때는 쿠키 사용 X.
      • 사용자가 쿠키를 지우거나 다른 브라우저를 열면 그 정보를 사용할 수 없기 때문
      • 쿠키는 단순히 요청과 요청 사이에 상태성(statefullness)을 부여하는 것에 가깝다.
       

      476. 쿠키 서명하기

    • 서명된 쿠키(signed cookie)
      • 쿠키 파서에 비밀 문자열을 전달해 서명된 쿠키 지원을 활성화할 수 있다.
      • 사용자에게 쿠키를 보낼 때 키-값을 직접 보내는 대신 서명된 쿠키로 보내는 것
      app.use(cookieParser('thisismysecret')); // 비밀 문자열 전달
      쿠키 파서가 쿠키에 사인할 때 비밀 문자열이 쓰인다.
      app.get('/getsignedcookie', (req, res) => {
          res.cookie('fruit', 'grape', { signed: true })
          res.send('OK SIGNED YOUR FRUIT COOKIE!')
      })
    • 서명은 정보를 감추기 위한 암호화 개념이 아니라, 클라이언트나 브라우저에게 보낸 원본 데이터와 돌려받은 데이터가 일치하는지 확인하기 위한 조작 방지 봉인 같은 개념
    • grape가 보인다. (암호화X)
      grape가 보인다. (암호화X)
    • req.cookies 에는 일반적인 무서명 쿠키가 들어 있고, 서명된 쿠키는 req.signedcookies 에 들어 있다.
    • notion image
    • 애플리케이션 - 쿠키 탭에서 서명된 쿠키를 조작하면 쿠키 무결성이 사라져 다음과 같이 나온다.
    •  
      notion image
      notion image
    • 비밀 키를 변경할 경우, 그 비밀 키로 서명되었던 모든 쿠키가 무효화 된다.
      • 보통 환경 변수이기 때문에 변경될 일 X. 안전함
       

      477. 선택: HMAC 서명하기

    • HMAC은 Hash-based-Message Authentication Code의 약자
    • 메시지의 무결성과 진정성을 확인하기 위해 메시지 인증 코드를 설정
      • 무결성: 데이터가 변함없는 것
      • 진정성: 데이터의 출처가 같은 소스 또는 신뢰할 만한 곳
    • 값과 비밀 키, sha256 해시함수로 쿠키에 서명을 한 후 base 64로 변환
    • notion image
      notion image
    • 서명 해제는 값과 비밀 키를 요구한다.
    •  

      478. 섹션 주제

    • Express의 세션 설정법
      • 세션을 활용하면 인증을 실행하거나, 사람들을 로그인 상태로 유지하거나, 관련 정보를 기억할 수 있다.
      • 세션은 쿠키와 함께 사용된다.
    • Flash Messages
      • 페이지에 있는 사용자에게 메시지를 공유하는 도구
       

      479. 세션 개요

    • 세션은 데이터를 영원히 저장하기 위한 것이 아니라, 하나의 요청에서 다음 요청으로 무언가를 전달하면서 그 경로를 추적하기 위한 것
      • 정보를 저장하는 서버의 데이터베이스와는 목적이 전혀 다르다.
      • 세션을 활용하면 본질적으로 무상태 프로토콜인 HTTP에 상태성을 부여할 수 있다.
    • 쿠키의 한계
      • 쿠키에 저장할 수 있는 정보량 즉, 크기의 제한
      • 서버 측에 정보를 저장하는 것만큼 안전하지 않음
    • 세션 주요 개념: 모든 데이터를 브라우저에 쿠키로 저장하고, 각각의 요청과 함께 모든 장바구니 정보를 전송하는 대신 서버 측에 그 정보를 남기고 데이터 저장소의 데이터와 일치하는 하나의 쿠키를 전송하는 것
    • 데이터를 단기 저장할 때 쓰이는 DB인 Redis와 비슷한 역할을 한다.
    •  

      480. Express 세션

    • 쿠키와 마찬가지로 세션은 Express 개념에만 적용되는 것이 아니라 HTTP와 웹 개발에도 모두 통용된다.
    • npm i express-session 패키지 설치
      • 이 모듈을 작동하기 위해 cookie-parser를 더이상 사용하지 않아도 된다.
    • 미들웨어를 인스턴스화할 때 비밀 키를 세션 함수에 전달해야 한다.
    • const session = require('express-session');
      
      const sessionOptions = { secret: 'thisisnotagoodsecret' }
      app.use(session(sessionOptions));
    • 세션 미들웨어를 사용하기만 해도 connect.sid 라는 새로운 쿠키가 추가된다.
      • Express 세션 쿠키의 이름 (sid는 session id를 뜻함)
      • 쿠키 삭제 후 재접속하면 다른 세션 id를 부여받는다.
    • 세션의 데이터는 서버 측에 저장되고, 개인 사용자나 적어도 개별 브라우저와 연결된다.
    • app.get('/viewcount', (req, res) => {
          if (req.session.count) {
              req.session.count += 1;
          } else {
              req.session.count = 1;
          }
          res.send(`You have viewed this page ${req.session.count} times`)
      })
    • 서버 엔드포인트에 요청을 할 때마다 쿠키가 전송된다.
    • 새로고침 할때마다 카운트 증가
      새로고침 할때마다 카운트 증가
    • Express 세션의 디폴트 저장소는 MemoryStore
      • 실제 DB가 아닌 메모리에 있다.
      • Redis 등을 사용하거나 Mongo 세션 저장소를 사용할 수도 있다. (프로덕션)
    • Express 세션은 쿠키를 가져와서 변조되지 않았다는 것(무결성)을 확인 후, 유효한 세션 id라면 세션 저장소 안을 살펴 id에 해당하는 정보를 찾고, req.session.count에 접근하게 된다.
    •  

      481. Express 세션 더 알아보기

    • resave 옵션은 요청 중 수정 사항이 없더라도 세션이 세션 저장소에 다시 저장될 수 있게 하는 것 → false로 설정하기
    • const sessionOptions = { secret: 'thisisnotagoodsecret', resave: false, saveUninitialized: false }
    • Express 세션이 자동으로 하나의 세션이나, 세션 내 하나의 스팟과 연결되는 적절한 쿠키를 다시 보낸다.
    • app.get('/register', (req, res) => {
          const { username = 'Anonymous' } = req.query;
          req.session.username = username;
          res.redirect('/greet')
      })
      
      app.get('/greet', (req, res) => {
          const { username } = req.session;
          res.send(`Welcome back, ${username}`)
      })
       

      482. 플래시 개요

    • 플래시는 기본적으로 세션에서 사용자에게 메시지를 출력해 내보내는 장소
      • 완료 메시지, 확인 메시지 등 한번 뜨고 사라지는 메시지를 말함
      • 동작이 끝나고 다른 페이지로 리다이렉트되기 전에 나타나는 것
    • npm i connect-flash 설치 (express-flash 도 있음)
    • const flash = require('connect-flash');
      
      app.use(flash());
    • 모든 요청 객체는 플래시 메시지에 사용하는 req.flash를 갖는다.
      • 작성된 미들웨어나 라우트 핸들러에 포함된 req 객체에 flash 메서드 사용 가능
      • 첫 번째 인자로 키 값을 전달하는 방식(error, success, danger, info 등)을 입력
      • 두 번째 인자에 메시지 입력
      • 리다이렉트 코드보다 먼저 작성해야 한다.
      app.get('/farms', async (req, res) => {
          const farms = await Farm.find({});
          res.render('farms/index', { farms, messages: req.flash('success') })
      })
      
      app.post('/farms', async (req, res) => {
          const farm = new Farm(req.body);
          await farm.save();
          req.flash('success', 'Successfully made a new farm!');
          res.redirect('/farms')
      })
       

      483. Res.locas와 플래시

    • 플래시 메시지 전달 방법 개선하기
      • 모든 템플릿과 뷰에서 메시지에 접근
      app.use((req, res, next) => {
          res.locals.messages = req.flash('success');
          next();
      })
    • 상단에 메시지를 표시할 partial을 작성하면 된다.
    • 484. 캠프그라운드 경로 빠져나오기

    • app.get, app.post, … 형태를 router.get, router.post, … 형태로 바꿔주기
    • app.js에서 라우터 지정하고, 필요한 코드들 옮기기
    • 경로 수정 ./../
    • const express = require('express');
      const router = express.Router();
      const catchAsync = require('../utils/catchAsync');
      const ExpressError = require('../utils/ExpressError');
      const Campground = require('../models/campground');
      const { campgroundSchema } = require('../schemas');
      
      const validateCampground = (req, res, next) => {
        const { error } = campgroundSchema.validate(req.body);
        if (error) {
          const msg = error.details.map((el) => el.message).join(',');
          throw new ExpressError(msg, 400);
        } else {
          next();
        }
      };
      
      router.get('/', catchAsync(async (req, res) => { ... }
      // ...
      routes/campgrounds.js
      const campgrounds = require('./routes/campgrounds');
      // ...
      app.use('/campgrounds', campgrounds);
      app.js
       

      485. 리뷰 경로 빠져나오기

    • 캠프그라운드 라우터 설정과 같은 방식으로 진행
    • 주의할 점은, 라우트를 작성할 때 정의한 더 많은 매개변수에 접근하려면 mergeParams를 true로 지정하면 된다.
    • const express = require('express');
      const router = express.Router({ mergeParams: true });
      
      const catchAsync = require('../utils/catchAsync');
      const ExpressError = require('../utils/ExpressError');
      
      const Campground = require('../models/campground');
      const Review = require('../models/review');
      const { reviewSchema } = require('../schemas');
      
      const validateReview = (req, res, next) => {
        const { error } = reviewSchema.validate(req.body);
        if (error) {
          const msg = error.details.map((el) => el.message).join(',');
          throw new ExpressError(msg, 400);
        } else {
          next();
        }
      };
      
      router.post('/', validateReview, catchAsync(async (req, res) => {
      routes/reviews.js
      const reviews = require('./routes/reviews');
      ../
      app.use('/campgrounds/:id/reviews', reviews);
       

      486. 정적 Assets 서비스

    • public 디렉토리를 설정해 정적 Assets 사용하기
    • app.use(express.static(path.join(_dirname, 'public')));
    • boilerplate.ejs에 있는 유효성 검사 관련 스크립트를 public 폴더로 옮기기
    • const forms = document.querySelectorAll('.validated-form');
      Array.from(forms).forEach(function (form) {
          form.addEventListener('submit', (event) => {
              if (!form.checkValidity()) {
                  event.preventDefault();
                  event.stopPropagation();
              }
              form.classList.add('was-validated')
          }, false)
      })
      public/javascripts/validateForms.js
      mongoose.connect('mongodb://localhost:27017/yelp-camp', {
        useNewUrlParser: true,
        useCreateIndex: true,
        useUnifiedTopology: true,
        useFindAndModify: false,
      });
      notion image
    • Mongoose 6버전에서는 옵션을 따로 지정해주지 않아도 항상 위와 같이 동작하므로 전부 지워도 된다.
    •  

      487. 세션 구성하기

    • 플래시 메시지와 인증을 추가하기 위해 Express 세션 추가하기 npm i express-session
    • const session = require('express-session');
      
      const sessionConfig = {
        secret: 'thisshouldbeabettersecret!',
        resave: false,
        saveUninitialized: true,
      };
      
      app.use(session(sessionConfig));
    • 지금은 Mongo 저장소 대신 개발 목적으로 쓰는 메모리 저장소를 사용
      • 서버 가동을 멈추거나 재개하면 바로 사라진다.
    • 세션 id가 생성된 것을 볼 수 있다.
    • notion image
    • expires만료 기한 설정하기 Date.now() 사용
      • 밀리초 단위이므로 그에 맞게 일주일 뒤로 설정
      const sessionConfig = {
        secret: 'thisshouldbeabettersecret!',
        resave: false,
        saveUninitialized: true,
        cookie: {
      		httpOnly: true,
          expires: Date.now() + 1000 * 60 * 60 * 24 * 7,
          maxAge: 1000 * 60 * 60 * 24 * 7,
        }
      };
      세션 id에 만료 기한이 지정됨 / HttpOnly에 디폴트로 체크되어 있음
      세션 id에 만료 기한이 지정됨 / HttpOnly에 디폴트로 체크되어 있음
    • 보안 코드 설정하기 httpOnly
      • (디폴트로 설정되어 있는 옵션이지만 코드에 명확하게 true로 선언함)
      • 쿠키에 httpOnly 플래그가 뜨면 클라이언트 측 스크립트에서 해당 쿠키에 접근할 수 없고, XSS에 결함이 있거나 사용자가 결함을 일으키는 링크에 접근하면 브라우저가 제 3자에게 쿠키를 유출하지 않도록 한다.
       

      488. 플래시 설정하기

    • 플래시 메시지를 띄우기 위해 npm i connect-flash
    • const flash = require('connect-flash');
      // ...
      app.use(flash());
    • req.flash에 키-값 쌍을 전달해 플래시를 생성한다.
    • 라우트 핸들러 코드 앞에 미들웨어로 정의하기
      • 템플릿으로 항상 success인 키에 자동으로 접근하게 해서 직접 전달할 필요 X.
      app.use((req, res, next) => {
        res.locals.success = req.flash('success');
        next();
      });
    • boilerplate 파일에서 <%- body %> 위에 <%= success %> 추가하기
    • res.locals.error = req.flash('error'); 도 추가
    •  

      489. Flash_Success 파셜

    • 부트스트랩 alert 클래스로 플래시 메시지 꾸미기
    • <% if(success && success.length) {%>
          <div class="alert alert-success alert-dismissible fade show" role="alert">
              <%= success %>
              <button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
          </div>
      <% } %>
      partials/flash.ejs - 강의 코드에서 수정해야 될 부분에 표시해 둠
      <!-- <%= success %> 대신 -->
      <%- include('../partials/flash')%>
    • campground와 reviews의 create, update, delete 라우트에 리다이렉트 전 코드 삽입
    • req.flash('success', 'Successfully made a new campground!');
       

      490. Flash_Error 파셜

      <% if(error && error.length) {%>
          <div class="alert alert-danger alert-dismissible fade show" role="alert">
              <%= error %>
              <button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
          </div>
      <% } %>
      partials/flash.ejs - 강의 코드에서 수정해야 될 부분에 표시해 둠
      router.get('/:id', catchAsync(async (req, res) => {
          const campground = await Campground.findById(req.params.id).populate('reviews');
          if (!campground) {
            req.flash('error', 'Cannot find that campground!');
            return res.redirect('/campgrounds');
          }
          res.render('campgrounds/show', { campground });
        })
      );
    • show 라우트에 캠핑장을 찾을 수 없으면 error 플래시 메시지가 뜨게 조건문을 추가해 준다.
    • notion image

      491. 섹션 주제

    • 인증 플로우
    • 해시 함수 이해
    • Bcrypt와 암호 솔트(Password Salts)
    • Passport 라이브러리
      • Node 앱에서 쉽게 인증을 만들 수 있는 도구
      • Facebook, Google, GitHub, Twitter 등 로그인 추가
       

      492. 인증과 권한부여

      인증(Authentication)

    • 인증이란 특정 사용자가 누구인지 확인하고, 그 사용자가 제대로 된 사용자가 맞는지 신원을 확인하는 과정
      • 가입 및 로그인
      • 보통은 username과 암호로 인증
      • 추가 인증으로는 얼굴 인식, 지문 인식 등

      권한(Authorization)

    • 권한이란 특정 사용자가 할 수 있는 행동을 확인하는 것
      • 사용자가 인증된 이후 일어나는 동작들
      • 관리자인가 아닌가
      • 접근 가능 여부, 편집/삭제가 가능한 대상 확인
       

      493. 패스워드를 저장하거나 하지 않는 방법

    • 암호를 그대로 저장해선 안된다.
    • 해시 함수로 암호를 처리하고 그 결과를 데이터베이스에 저장
    • 로그인을 시도하려고 입력한 암호를 똑같은 해시 함수로 처리해 DB에 저장된 값과 비교
    •  

      494. 암호화된 해시 함수

    • 일반적으로 해시는 임의적 크기의 입력 값을 넣어 고정된 크기의 출력 값을 얻는 것을 말한다. 하지만 암호화 해시 함수를 말할 때는 보통 암호 해시 함수와 암호 안전 해시 함수를 일컫는다.
    • 해시 함수는 단방향 함수이고, 역추적 불가능
    • 입력값이 한 글자만 바뀌더라도 출력 값은 완전히 달라진다.
    • 결정론적(Deterministic) 알고리즘
      • 동일한 입력 값은 항상 같은 값으로 출력된다.
    • 동일한 출력값이 도출되어선 안된다. (극히 드문 확률)
    • 암호 해시 함수는 느린 함수여야 한다. (의도적으로)
      • 다양한 조합을 빠른 속도로 시도하지 못하게 하기 위함
       

      495. 패스워드 솔트

    • 솔트는 암호를 해시할 때, 역추적을 방지하기 위한 단계
      • 암호를 해시할 때 암호의 시작이나 끝에 임의의 값을 추가한다.
      • 해시 출력 값에 솔트가 추가된다.
    • 이렇게 하는 이유는 솔트를 넣으면 아주 다른 출력 값을 만들어내기 때문에, 해커들의 역방향 조회 테이블을 무력화할 수 있기 때문
      • 여러 사이트에서 같은 암호를 쓰는 사람들이나 잘 알려진 쉬운 암호를 사용하는 다수의 사람들을 보호할 수 있다.
       

      496. Bcrypt 개요

    • Bcrypt는 솔트를 생성해주는 암호 해시 함수 라이브러리
      • Bcrypt의 B는 복어(Blowfish cipher), crypt는 암호화를 뜻한다.
    • bcrypt 패키지와 bcrypt.js 패키지의 차이
      • bcrypt.js는 JavaScript로만 쓰여져서 클라이언트(브라우저)에서도 실행할 수 있다.
    • npm i bcrypt 노드 버전 설치
    • .genSalt() 메서드는 암호 솔트를 생성한다.
      • 첫 번째 인수로 saltRounds를 전달하는데, round는 해시의 난이도로, 숫자가 높을 수록 해시를 계산하는 데 걸리는 시간이 증가한다. (암호를 몇 회 해시할 지를 결정)
      • 목적은 과정을 느리게 만드는 데 있다.
    • .hash() 메서드는 생성된 솔트와 암호를 조합해 해시 암호를 반환한다.
    • const bcrypt = require('bcrypt');
      
      const hashPassword = async (pw) => {
        const salt = await bcrypt.genSalt(12);
        const hash = await bcrypt.hash(pw, salt);
        console.log(salt);
        console.log(hash);
      };
      
      hashPassword('monkey');
      notion image
    • .compare() 메서드로 입력된 암호를 해시해서 데이터베이스에 저장한 값과 비교
    • https://github.com/kelektiv/node.bcrypt.js#to-check-a-password
      const login = async (pw, hashedPw) => {
        const result = await bcrypt.compare(pw, hashedPw);
        if (result) { // true
          console.log('LOGGED YOU IN! SUCCESSFUL MATCH!');
        } else { // false
          console.log('INCORRECT!');
        }
      };
      
      login('monkey', '$2b$12$dYG/3tlvvS5Qwp4jesKMSeROTIYWyVqiUPzfa8stWKrrC8U9K7XY6');
      notion image
    • hash에 두 번째 인자로 횟수를 전달하면 전달한 횟수를 사용해 솔트를 생성하고 전달된 암호를 반환하기 때문에, 솔트를 별개로 생성해주지 않아도 된다.
    • const hashPassword = async (pw) => {
        const hash = await bcrypt.hash(pw, 12);
        console.log(hash);
      };
       

      497. Auth Demo: 설정

    • 유저 스키마 및 모델 생성
    • const { Schema, default: mongoose } = require('mongoose');
      
      const userSchema = new Schema({
        username: {
          type: String,
          required: [true, 'Username cannot be blank'],
        },
        password: {
          type: String,
          required: [true, 'Password cannot be blank'],
        },
      });
      
      module.exports = mongoose.model('User', userSchema);
      models/user.js
    • 신규 사용자 가입 기능 구현 위해 폼 만들기
    • const express = require('express');
      const app = express();
      const User = require('./models/user');
      
      app.set('view engine', 'ejs');
      app.set('views', 'views');
      
      app.get('/register', (req, res) => {
        res.render('register');
      });
      
      app.get('/secret', (req, res) => {
        res.send('THIS IS SECRET! YOU CANNOT SEE ME UNLESS YOU ARE LOGGED IN!');
      });
      
      app.listen(3000, () => {
        console.log('SERVING YOUR APP!');
      });
      index.js
      <!DOCTYPE html>
      <html lang="ko">
      
      <head>
          <meta charset="UTF-8">
          <meta http-equiv="X-UA-Compatible" content="IE=edge">
          <meta name="viewport" content="width=device-width, initial-scale=1.0">
          <title>Register</title>
      </head>
      
      <body>
          <h1>Sign up!</h1>
          <form action="">
              <div>
                  <label for="username">Enter Username:</label>
                  <input type="text" name="username" id="username" placeholder="username">
              </div>
              <div>
                  <label for="password">Enter Password:</label>
                  <input type="password" name="password" id="password" placeholder="password">
              </div>
              <button>Sign Up</button>
          </form>
      </body>
      
      </html>
      views/register.ejs
       

      498. Auth Demo: 등록

    • mongoose로 express에 mongo 연결하고 post 라우트 만들기
      • register.ejs 수정 <form action="/register" method="POST">
      const mongoose = require('mongoose');
      
      mongoose
        .connect('mongodb://localhost:27017/authDemo', { useNewUrlParser: true })
        .then(() => {
          console.log('MONGO CONNECTION OPEN!!!');
        })
        .catch(() => {
          console.log('OH NO MONGO CONNECTION ERROR!!!!');
          console.log(err);
        });
      
      app.use(express.urlencoded({ extended: true })); // req.body 파싱
      
      app.post('/register', async (req, res) => {
        res.send(req.body);
      })
    • Bcrypt로 암호 처리해서 db에 저장 (여기선 오류 처리는 안하고 인증 로직에만 집중)
    • const bcrypt = require('bcrypt');
      
      app.get('/', (req, res) => {
        res.send('THIS IS THE HOME PAGE')
      });
      
      app.post('/register', async (req, res) => {
        const { password, username } = req.body;
        const hash = await bcrypt.hash(password, 12);
        const user = new User({
          username,
          password: hash,
        });
        await user.save();
        res.redirect('/');
      });
      성공적으로 암호를 해시해 데이터베이스에 저장함
      성공적으로 암호를 해시해 데이터베이스에 저장함
       

      499. Auth Demo: 로그인

    • 로그인 기능 구현을 위해 로그인 폼과 라우트 만들기
      • 암호를 틀렸거나 사용자를 찾을 수 없는 경우, 무엇이 틀렸는지 정확히 알려주면 안된다. ⇒ ‘사용자 이름 또는 암호를 찾을 수 없다’고 해야 함. 힌트 X
      app.get('/login', (req, res) => {
        res.render('login');
      });
      
      app.post('/login', async (req, res) => {
        const { username, password } = req.body;
        const user = await User.findOne({ username });
        const validPassword = await bcrypt.compare(password, user.password);
        if (validPassword) {
          res.send('YAY WELCOME!!');
        } else {
          res.send('TRY AGAIN');
        }
      });
      비밀번호를 정확하게 입력
      비밀번호를 정확하게 입력
      성공
      성공
       

      500. Auth Demo: 로그인한 상태로 세션 유지

    • 로그인은 로그아웃 즉, 세션이 만료되기 전까지 로그인 상태가 유지된다는 뜻을 함축하고 있다.
      • 일주일 후에도 재방문하지 않으면 로그아웃되는 식으로 만료 기한을 정할 수 있다.
    • 사용자의 로그인 여부 확인하기 위해 세션에 로그인한 사용자 ID를 저장하기
      • npm i express-session
      const session = require('express-session');
      // ...
      app.use(session({ secret: 'notagoodsecret' }));
      // ...
      app.post('/login', async (req, res) => {
        const { username, password } = req.body;
        const user = await User.findOne({ username });
        const validPassword = await bcrypt.compare(password, user.password);
        if (validPassword) {
          req.session.user_id = user._id;
          res.send('YAY WELCOME!!');
        } else {
          res.send('TRY AGAIN');
        }
      });
      
      app.get('/secret', (req, res) => {
        if (!req.session.user_id) {
          res.redirect('/login');
        }
        res.send('THIS IS SECRET! YOU CANNOT SEE ME UNLESS YOU ARE LOGGED IN!');
      });
       

      501. Auth Demo: 로그아웃

    • 세션의 작동 방식
      • 세션은 전부 서버에 저장되고, 클라이언트로 반환되는 서명된 쿠키가 있다.
      • 서명된 쿠키로는 유효성 검사를 할 수 없다.
    • 로그아웃을 위한 post 라우트 만들고, 세션에서 사용자 ID 제거하기
    • app.post('/logout', (req, res) => {
        req.session.user_id = null;
      	// req.session.destroy(); 💡 세션을 파기해서 로그아웃 하는 방법
        res.redirect('/login');
      });
      <h1>Secret Page!</h1>
      <form action="/logout" method="POST">
          <button>Sign Out</button>
      </form>
      secret.ejs
    • /secret 라우트를 보호해 로그인 한 유저만 접근할 수 있게 하기
    • app.get('/secret', (req, res) => {
        if (!req.session.user_id) {
          return res.redirect('/login');
        }
        res.render('secret');
      });
       

      502. Auth Demo: 로그인 미들웨어 요구

    • 보호할 엔드포인트가 보통 여러 개 있기 때문에, 도와줄 미들웨어 만들기
    • 사용자의 로그인 여부를 확인하는 미들웨어 작성
    • const requireLogin = (req, res, next) => {
        if (!req.session.user_id) {
          return res.redirect('/login');
        }
        next();
      }
      // ...
      app.get('/secret', requireLogin, (req, res) => {
        res.render('secret');
      });
      
      app.get('/topsecret', requireLogin, (req, res) => {
        res.send('TOP SECRET!!!');
      });
       

      503. Auth Demo: 모델 메서드 리팩토링

    • 라우트 규모 축소시키기 - 코드는 라우트 핸들러 밖으로 빼낼 수록 좋다.
    • 모델의 정적 메서드 추가하기
    • const bcrypt = require('bcrypt');
      
      userSchema.statics.findAndValidate = async function (username, password) {
        const foundUser = await this.findOne({ username });
        const isValid = await bcrypt.compare(password, foundUser.password);
        return isValid ? foundUser : false;
      }
      models/user.js
      app.post('/login', async (req, res) => {
        const { username, password } = req.body;
        const foundUser = await User.findAndValidate(username, password)
        if (foundUser) {
          req.session.user_id = foundUser._id;
          res.redirect('/secret');
        } else {
          res.redirect('/login');
        }
      });
    • 사용자를 등록할 때 먼저 해시한 암호를 new User에 추가한 뒤 save하는 방식 대신, Mongoose와 모델로 암호를 해시하도록 미들웨어를 추가할 수 있다.
    •  
       

       
       
      본 스터디는 Udemy의 <【한글자막】 The Web Developer 부트캠프 2022> 강의를 활용해 진행됐습니다. 강의에 대한 자세한 정보는 아래에서 확인하실 수 있습니다.
       
       
      프밍 스터디는 Udemy Korea와 함께 합니다.
       
       

       
       
원하는 스터디가 없다면? 다른 스터디 개설 신청하기
누군가 아직 원하는 스터디를 개설하지 않았나요? 여러분이 직접 개설 신청 해 주세요!
이 포스트는
"웹 개발 실전 프로젝트" 스터디의 진행 결과입니다
진행중인 스터디
웹 개발 실전 프로젝트
JavaScript를 이용한 DOM 조작으로 프론트엔드 분야를 배우고, Node JS와 Express, DB, HTTP, REST API 등 백앤드 지식을 학습할수 있습니다. 이론만 공부하는 스터디가 아니라 13개 이상의 프로젝트로 이루어진 실습 위주의 커리큘럼으로 개인 프로젝트 결과물을 가져갈 수 있다는 메리트가 있습니다.
조재홍 멘토
이로운 개발자가 되고 싶은 조재홍입니다.