$ yarn add @elastic/elasticsearch

github가면 예제랑 사용방법이 다 나와있음

이 문제는 react에서 호출할때 생기는 문제로 frontend의 문제가 아닌 backend에서 처리를 해줘야 하는 에러이다.
CORS policy는 도메인이 이름이 다르거나, 뭐 포트가 다르거나 이러면 정책상 접근을 못하도록 block을 해준다.그러니 열어주면 된다.

Access to XMLHttpRequest at 'http://localhost:4000/api/aptComplex' from origin 'http://localhost:3000' has been blocked by CORS policy: Response to preflight request doesn't pass access control check: No 'Access-Control-Allow-Origin' header is present on the requested resource.
  • cors를 설치해야하는데, express가 아닌 koa를 사용했으므로 koa에 맞는 패키지를 설치해야 한다.
  • koa-cors-github
  • 아마도 express 용으로 cors을 다운받았다면 node res.setHeader is not a function의 에러가 난다.
$ npm install @koa/cors@2 --save
const Koa = require('koa');
const cors = require('@koa/cors');

const app = new Koa();
app.use(cors());
  • 글 삭제/수정은 작성자만 할 수 있어야 한다.
  • 미들웨어를 수정
  • /src/lib/checkLoggedIn.js를 생성
  • /src/api/posts/posts.ctrl.jscheckOwnPost, getPostById를 추가
export const checkOwnPost = (ctx, next) => {
  const { user, post } = ctx.state;
  if (post.user._id.toString() !== user._id) {
    ctx.status = 403;
    return;
  }
  return next();
};

export const getPostById = async (ctx, next) => {
  const { id } = ctx.params;
  if (!ObjectId.isValid(id)) {
    ctx.status = 400; // Bad Request
    return;
  }
  try {
    const post = await Post.findById(id);
    if (!post) {
      ctx.status = 404; // Not found
      return;
    }
    ctx.state.post = post;
    return next();
  } catch (e) {
    ctx.throw(500, e);
  }
  return next();
};
  • /src/api/posts/index.js에 아래와 같이 미들웨어 추가
posts.get('/', postsCtrl.list);
posts.post('/', checkLoggedIn, postsCtrl.write);

const post = new Router(); // /api/posts/:id
post.get('/', postsCtrl.read);
post.delete('/', checkLoggedIn, postsCtrl.checkOwnPost, postsCtrl.remove);
post.patch('/', checkLoggedIn, postsCtrl.checkOwnPost, postsCtrl.update);

posts.use('/:id', postsCtrl.getPostById, post.routes());
export default posts;

username/tags로 포스트 필터

  • 포스트 조회, 태그 조회
/* 포스트 목록 조회
GET /api/posts
/api/posts?username=jhl
/api/posts?tag=다리디리
*/
export const list = async ctx => {
  const page = parseInt(ctx.query.page | '1', 10);

  if (page < 1) {
    ctx.status = 400;
    return;
  }

  const { tag, username } = ctx.query;
  const query = {
    // username, tag가 유효할때만 객체 안에 해당 값을 넣겠다는 의미
    ...(username ? { 'user.username': username } : {}),
    ...(tag ? { tags: tag } : {}),
  };

  try {
    const posts = await Post.find(query)
      .sort({ _id: -1 })
      .limit(10)
      .skip((page - 1) * 10)
      .exec();
    const postCount = await Post.countDocuments(query).exec();
    ctx.set('Last-Page', Math.ceil(postCount / 10));
    ctx.body = posts
      .map(post => post.toJSON())
      .map(post => ({
        ...post,
        body:
          post.body.length < 200 ? post.body : `${post.body.slice(0, 200)}...`,
      }));
  } catch (e) {
    ctx.throw(500, e);
  }
};
  • 토큰 기반 인증 구현
  • hash를 만드는 함수와 has 검증을 하는 함수
$ yarn add bcrypt

모델 메소드 만들기

  • 모델에서 사용할 수 있는 함수
  • 인스턴스 메서드 모델을 통해 만든 문서 인스턴스에서 사용할 수 있는 함수
  • static 메소드로 모델에서 바로 사용할 수 있는 함수

instance method

import mongoose, { Schema } from 'mongoose';
import bcrypt from 'bcrypt';

const UserSchema = new Schema({
  username: String,
  hashedPassword: String,
});

UserSchema.methods.setPassword = async function(password) {
  const hash = await bcrypt.hash(password, 10);
  this.hashedPassword = hash;
};

UserSchema.methods.checkPassword = async function(password) {
  const result = await bcrypt.compare(password, this.hashedPassword);
  return result; // true or false
};

const User = mongoose.model('User', UserSchema);
export default User;

static method

UserSchema.statics.findByUsername = function(username) {
  return this.findOne({ username });
};

회원 인증 API 만들기

src/api/auth/auth.ctrl.js

import Joi from 'joi';
import User from '../../models/user';

/*
POST /api/auth/register
{
  username: 'zzz',
  password: 'zzz'
}
*/
export const register = async ctx => {
  const schema = Joi.object().keys({
    username: Joi.string()
      .alphanum()
      .min(3)
      .max(20)
      .required(),
    password: Joi.string().required(),
  });
  const result = Joi.validate(ctx.request.body, schema);
  if (result.error) {
    ctx.status = 400;
    ctx.body = result.error;
    return;
  }
  const { username, password } = ctx.request.body;
  try {
    const exists = await User.findByUsername(username);
    if (exists) {
      ctx.status = 409; // conflict
      return;
    }
    const user = new User({
      username,
    });
    await user.setPassword(password);
    await user.save();
    ctx.body = user.serialize(); // delete hashedPassword
  } catch (e) {
    ctx.throw(500, e);
  }
};

/*
POST /api/auth/login
{
  username: 'zzz'
  password: 'zzz'
}
*/
export const login = async ctx => {
  const { username, password } = ctx.request.body;

  if (!username || !password) {
    ctx.status = 401; // Unauthorized
    return;
  }

  try {
    const user = await User.findByUsername(username);
    if (!user) {
      ctx.status = 401;
      return;
    }

    const valid = await user.checkPassword(password);
    if (!valid) {
      ctx.status = 401;
      return;
    }
    ctx.body = user.serialize();
  } catch (e) {
    ctx.throw(500, e);
  }
};

export const check = async ctx => {};
export const logout = async ctx => {}; 

토큰 발급 및 검증

$ yarn add jsonwebtoken
$ openssl rand -hex 64
  • root의 .envJWT_SECRET값으로 설정
# .env
JWT_SECRET=dflkjdlkfdjflkdjflkjd
  • 위 키는 JWT 토큰의 서명을 만드는 과정에 사용되기 때문에 외부에 절대 공개되면 안됨
  • user 모델 파일에서 generateToken이라는 인스턴스 메서드 생성
UserSchema.methods.generateToken = function() {
  const token = jwt.sign(
    // 토큰안에 넣고 싶은 데이터
    {
      _id: this.id,
      username: this.username,
    },
    // JWT 암호
    process.env.JWT_SECRET,
    {
      expiresIn: '7d', // 7일동안 토큰 유효
    },
  );
  return token;
}; 
  • 회원가입과 로그인에 성공했을때 토큰을 사용자에게 전달
  • 사용자가 브라우저에서 토큰을 사용
    • localStorage, sessionStorage
      • 매우 편리하고 구현이 쉽지만, 쉽게 해킹이 가능 (XSS(Cross Site Scripting)
    • 브라우저 쿠키에 담아서 사용
      • httpOnly 속성을 활성화하면 자바스크립트등을 통해 쿠키를 조회할 수 없음
      • CSRF (Cross Site Request Forgery) 공격에 취약
      • 토큰을 쿠키에 담으면 사용자가 서버로 요청할때 무조건 토큰이 함께 전달
        • 내가 모르는 상황에서 글이 작성, 삭제, 탈퇴가 가능...
      • CSRF 토큰 사용 및 Referer 검증 등의 방식으로 제대로 막을 수 있는 반면
      • XSS는 보안장치를 적용해도 개발자가 놓칠 수 있는 다양한 취약점으로 공격 가능
    const token = user.generateToken();
    ctx.cookies.set('access_token', token, {
      maxAge: 1000 * 60 * 60 * 24 * 7, // 7days
      httpOnly: true,
    });
  • login, register에 다음과 같이 추가하면 access_token이 Header에서 확인

토큰 검증

  • 사용자의 토큰을 확인한 후 검증 작업
  • src/lib아래 다음과 같은 파일을 생성
import jwt from 'jsonwebtoken';

const jwtMiddleware = (ctx, next) => {
  const token = ctx.cookie.get('access_token');
  if (!token) return next(); // token is not exists
  try {
    const decoded = jwt.verify(token, process.env.JWT_SECRET);
    console.log(decoded);
    return next();
  } catch (e) {
    // fail token validation
    return next();
  }
};

export default jwtMiddleware;

토큰 재발급받기

  • 토큰의 만료 기간을 7일로 했는데 만료되면 재발급
  • generateToken에서 토큰 유효기간을 3일로 변경하고 아래 함수가 실행되는지 확인
    // 토큰의 남은 유효 기간이 3.5일 미만이면 재발급
    const now = Math.floor(Date.now() / 1000);
    if (decoded.exp - now < 60 * 60 * 24 * 3.5) {
      const user = await User.findById(decoded._id);
      const token = user.generateToken();
      ctx.cookies.set('access_token', token, {
        maxAge: 1000 * 60 * 60 * 24 * 7, // 7days
        httpOnly: true,
      });
    }

로그아웃

  • 로그아웃은 access_token을 지워주면 된다.
export const logout = async ctx => {
  ctx.cookies.set('access_token');
  ctx.status = 240; // No Content
};
  • JWT (Json Web Token)
  • 데이터가 JSON으로 이루어져 있는 토큰

세션 기반 인증 vs 토큰 기반 인증

  • 사용자의 로그인 상태를 서버에서 처리
    • 세션 기반 인증
    • 토큰 기반 인증

세션 기반 인증

  • 서버가 사용자의 로그인 상태를 기억
  • 사용자 > 로그인 > 서버 > 정보저장 > 세션 id 발급 > 요청 > 세션 조회 > 응답
  • 세션 id는 브라우저의 쿠키에 저장
  • 세션 저장소: 메모리, 디스크, 데이터베이스
  • 단점
    • 서버를 확장하기가 어려움
      • 서버의 인스턴스가 여러개가 되면 세션 공유가 필요해 별도의 데이터베이스를 만들어야 한다

토큰 기반 인증

  • 로그인 이후 서버가 만들어주는 문자열
    • 문자열에는 로그인 정보가 들어있고, 해당 정보가 서버에서 발급되었음을 증명하는 서명
  • 사용자 > 로그인 > 서버 > 토큰 발급 > 토큰과 함께 요청 > 토큰 유효성 검사 > 응답
  • 서명 데이터는 해싱 알고리즘을 통해 생성
    • HMAC SHA256, RSA, SHA256 알고리즘
  • 서버에서 만들어주기때문에 무결성이 보장
    • 정보가 변경되거나 위조되지 않음
  • API 요청할때마다 토큰을 함께 요청
  • 장점
    • 로그인 정보를 기억하기 위해 사용하는 리소스가 적다는 것
    • 확장성이 높다. (인스턴스끼리 로그인 상태를 공유할 필요가 없음)
  • 포스트 목록을 볼때 한 페이지에 보이는 포스트의 개수는 적당히 보여줘야 한다.
  • 전체 포스트를 가져온다면 network I/O가 상당할것이다
  • 포스트 내용도 동일하게 전부가 아닌 일부만 보여주는게 I/O를 줄이는데 도움이 될것!
export const list = async ctx => {
  const page = parseInt(ctx.query.page | '1', 10);

  if (page < 1) {
    ctx.status = 400;
    return;
  }

  try {
    const posts = await Post.find()
      .sort({ _id: -1 })
      .limit(10)
      .skip((page - 1) * 10)
      .exec();
    const postCount = await Post.countDocuments().exec();
    ctx.set('Last-Page', Math.ceil(postCount / 10));
    ctx.body = posts
  • 위에 sort는 최근에 작성한 글을 먼저 오도록
  • limit은 10개의 post만 가져오도록
  • skip은 pagination을 할때 사용
    • skip(10)은 10개 뒤로 이동
  • Last-Page의 값은 Header에 함께 포함

body에 대한 처리

    ctx.body = posts
      .map(post => post.toJSON())
      .map(post => ({
        ...post,
        body:
          post.body.length < 200 ? post.body : `${post.body.slice(0, 200)}...`,
      }));
  • 결과로 가져온 body에서 ㅅ길이를 조절하기 위해서는 다음과 같이 body를 가져와 줄이면 된다.
  • 위 결과는 서버쪽에서 처리하는 내용, db에서도 처리가 가능할까?

ObjectId 검증

  • read, remove, update의 경우 ObjectId 검증이 필요
  • 각각의 함수에 검증 중복 코드 보다는 라우트에 쉽게 적용
  • controller에 아래와 같이 작성
import mongoose from 'mongoose';

const { ObjectId } = mongoose.Types;

export const checkObjectId = (ctx, next) => {
  const { id } = ctx.params;
  if (!ObjectId.isValid(id)) {
    ctx.status = 400; // Bad Request
    return;
  }
  return next();
};

sr/api/posts/index.js

posts.get('/:id', postsCtrl.checkObjectId, postsCtrl.read);
posts.delete('/:id', postsCtrl.checkObjectId, postsCtrl.remove);
posts.patch('/:id', postsCtrl.checkObjectId, postsCtrl.update);

// 위 코드에서 공통적인 router를 아래와 같이 작성 가능
const post = new Router(); // /api/posts/:id
post.get('/', postsCtrl.read);
post.delete('/', postsCtrl.remove);
post.patch('/', postsCtrl.update);

posts.use('/:id', postsCtrl.checkObjectId, post.routes());

Request Body 거증

  • write, update의 요청 내용을 검증
  • title, body, tags 값을 모두 전달 받았는지
  • 라이브러리 Joi
$ yarn add joi
import Joi from 'joi';
(...)
export const write = async ctx => {
  const schema = Joi.object().keys({
    title: Joi.string().required(),
    body: Joi.string().required(),
    tags: Joi.array()
      .items(Joi.string())
      .required(),
  });
  const result = Joi.validate(ctx.request.body, schema);
  if (result.error) {
    ctx.status = 400;
    ctx.body = result.error;
    return;
  }
  • 특정 필드가 반드시 존재하지 않을때는 아래와 같이 required()를 제거하고
export const update = async ctx => {
  const { id } = ctx.params;

  const schema = Joi.object().keys({
    title: Joi.string(),
    body: Joi.string(),
    tags: Joi.array().items(Joi.string()),
  });
  const result = Joi.validate(ctx.request.body, schema);
  if (result.error) {
    ctx.status = 400; // bad request
    ctx.body = result.error;
    return;
  } 
import Post from '../../models/post';

/* 포스트 작성
POST /api/posts
{ title, body }
*/
export const write = async ctx => {
  const { title, body, tags } = ctx.request.body;
  const post = new Post({
    title,
    body,
    tags,
  });
  try {
    await post.save();
    ctx.body = post;
  } catch (e) {
    ctx.throw(500, e);
  }
};

/* 포스트 목록 조회
GET /api/posts
*/
export const list = async ctx => {
  try {
    const posts = await Post.find().exec();
    ctx.body = posts;
  } catch (e) {
    ctx.throw(500, e);
  }
};

/* 특정 포스트 조회
GET /api/posts/:id
*/

export const read = async ctx => {
  const { id } = ctx.params;
  try {
    const post = await Post.findById(id).exec();
    if (!post) {
      ctx.status = 404; // Not Found
      return;
    }
    ctx.body = post;
  } catch (e) {
    ctx.throw(500, e);
  }
};

/* 특정 포스트 제거
DELETE /api/posts/:id
*/
export const remove = async ctx => {
  const { id } = ctx.params;
  try {
    await Post.findByIdAndRemove(id).exec();
    ctx.status = 204; // No Content
  } catch (e) {
    ctx.throw(500, e);
  }
};

/* 포스트 수정 (특정 필드 변경)
PATCH /api/posts/:id
{ title, body }
*/
export const update = async ctx => {
  const { id } = ctx.params;
  try {
    const post = await Post.findByIdAndUpdate(id, ctx.request.body, {
      // false: 업데이트 이전의 데이터 반환
      // true:  업데이트된 데이터를 반환
      new: true,
    }).exec();
    if (!post) {
      ctx.status = 404;
      return;
    }
    ctx.body = post;
  } catch (e) {
    ctx.throw(500, e);
  }
}; 

+ Recent posts