• 토큰 기반 인증 구현
  • 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
};
  • 포스트 목록을 볼때 한 페이지에 보이는 포스트의 개수는 적당히 보여줘야 한다.
  • 전체 포스트를 가져온다면 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);
  }
}; 
  • 각 라우터에서 처리해야하는 함수 코드가 길면 유지보수가 어려우니
  • 라우트에서 처리하는 함수들만 모아서 컨트롤러(controller)를 만들자
# yarn add koa-bodyparser

src/api/posts/posts.ctrl.js

let postId = 1; // init

// posts data

const posts = [
  {
    id: 1,
    title: '제목',
    body: '내용',
  },
];

/* 포스트 작성
POST /api/posts
{ title, body }
*/
exports.write = ctx => {
  const { title, body } = ctx.request.body;
  postId += 1;
  const post = { id: postId, title, body };
  posts.push(post);
  ctx.body = post;
};

/* 포스트 목록 조회
GET /api/posts
*/
exports.list = ctx => {
  ctx.body = posts;
};

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

exports.read = ctx => {
  const { id } = ctx.params; // default datatype: string
  const post = posts.find(p => p.id.toString() === id);
  if (!post) {
    ctx.status = 404;
    ctx.body = {
      message: '포스트가 존재하지 않습니다',
    };
    return;
  }
  ctx.body = post;
};

/* 특정 포스트 제거
DELETE /api/posts/:id
*/
exports.remove = ctx => {
  const { id } = ctx.params;
  const index = posts.findIndex(p => p.id.toString() === id);

  if (index === -1) {
    ctx.status = 404;
    ctx.body = {
      message: '포스트가 존재하지 않습니다',
    };
    return;
  }
  posts.splice(index, 1);
  ctx.status = 204; // No Content
};

/* 포스트 수정 (교체)
PUT /api/posts/:id
{ title, body }
*/
exports.replace = ctx => {
  const { id } = ctx.params;
  const index = posts.findIndex(p => p.id.toString() === id);
  if (index === -1) {
    ctx.status = 404;
    ctx.body = {
      message: '포스트가 존재하지 않습니다',
    };
    return;
  }
  posts[index] = {
    id,
    ...ctx.request.body,
  };
  ctx.body = posts[index];
};

/* 포스트 수정 (특정 필드 변경)
PATCH /api/posts/:id
{ title, body }
*/
exports.update = ctx => {
  const { id } = ctx.params;
  const index = posts.findIndex(p => p.id.toString() === id);
  if (index === -1) {
    ctx.status = 404;
    ctx.body = {
      message: '포스트가 존재하지 않습니다',
    };
    return;
  }
  posts[index] = {
    ...posts[index],
    ...ctx.request.body,
  };
  ctx.body = posts[index];
};

src/api/posts/index.js

const Router = require('koa-router');
const postsCtrl = require('./posts.ctrl');

const posts = new Router(); 

posts.get('/', postsCtrl.list);
posts.post('/', postsCtrl.write);
posts.get('/:id', postsCtrl.read);
posts.delete('/:id', postsCtrl.remove);
posts.put('/:id', postsCtrl.replace);
posts.patch('/:id', postsCtrl.update);

module.exports = posts; 
  • 여러개의 Router를 생성하게 될텐데, 이렇게 되면 유지보수가 이후에는 어려워질수 있으니 router 모듈화를 진행
  • src/api/index.js 에 api에 관련된 호출을 나열

src/index.js

const Koa = require('koa');
const Router = require('koa-router');

const api = require('./api');

const app = new Koa();
const router = new Router();

// 라우터 설정
router.use('/api', api.routes()); // add api router

// app instance에 라우터 적용
app.use(router.routes()).use(router.allowedMethods());

app.listen(4000, () => {
  console.log('Listening to port 4000');
});

src/api/index.js

const Router = require('koa-router');
const api = new Router();

api.get('/test', ctx => {
  ctx.body = 'test 성공';
});

module.exports = api;
  • src/api/index.js를 생성하고, src/index.jsrouter.use를 통해 router를 등록해주면 된다.
  • 이때 주의해야할점은 src/api/index.js에서 반드시 exports를 해줘야 한다.

posts API 생성하기

  • src/api/posts/index.js에 아래와 같이 posts에 관련된 api의 목록들을 정의한다.
  • 정의한 route를 src/api/index.js에 적용을 해주면 된다.

src/api/index.js

const Router = require('koa-router');
const posts = require('./posts');

const api = new Router();

api.use('/posts', posts.routes());

module.exports = api;

src/api/posts/index.js

const Router = require('koa-router');
const posts = new Router();

const printInfo = ctx => {
  ctx.body = {
    method: ctx.method,
    path: ctx.path,
    params: ctx.params,
  };
};

posts.get('/', printInfo);
posts.post('/', printInfo);
posts.get('/:id', printInfo);
posts.delete('/:id', printInfo);
posts.put('/:id', printInfo);
posts.patch('/:id', printInfo);

module.exports = posts;
  • 위 같이 정의하고 http://localhost:4000/api/posts/12을 호출하면 아래와 같은 결과가 나온다.

REST API가 없고, React에서 데이터를 가지고와 화면에 뿌리는 예제를 하고 싶다면 JSONPlaceholder를 이용해 JSON 데이터 샘플을 사용을 할 수 있다. 주소는 여기로...

fetch('https://jsonplaceholder.typicode.com/todos/1')
  .then(response => response.json())
  .then(json => console.log(json))

또는

  const onClick = async () => {
    try {
      const response = await axios.get(
        'https://jsonplaceholder.typicode.com/todos/1'
      );
      setData(response.data);
    } catch (e) {
      console.log(e);
    }
  };

출력결과

{
  "userId": 1,
  "id": 1,
  "title": "delectus aut autem",
  "completed": false
}

+ Recent posts