리액트를 다루는 기술 ( JWT를 통한 회원 인증 시스템 구현하기 )

23-1. JWT의 이해

JWT ( JSON Web Token ) : 데이터가 JSON으로 이루어져 있는 토큰을 의미. 두 개체가 서로 안전하게 정보를 주고받을 수 있도록 웹 표준으로 정의된 기술

23-1-1. 세션 기반 인증과 토큰 기반 인증의 차이

사용자의 로그인 상태를 서버에서 처리하는 데 사용할 수 있는 대표적인 두 가지 인증 방식이 있다.

23-1-1-1. 세션 기반 인증 시스템

세션을 기반으로 인증 시스템을 만든다는 것서버가 사용자가 로그인 중임을 기억하고 있다는 뜻이다.

세션 기반 인증 시스템에서

  1. 사용자가 로그인 하면
  2. 서버는 세션 저장소에 사용자의 정보를 조회하고 세션 id를 발급
  3. 발급된 id는 주로 브라우저의 쿠키에 저장
  4. 사용자가 다른 요청을 보낼 때마다 서버는 세션 저장소에서 세션을 조회한 후 로그인 여부를 결정하여 작업을 처리하고 응답

세션 저장소는 주로 메모리, 디스크, 데이터베이스 등을 사용한다.

단점 : 서버를 확장하기가 번거로워질 수 있다는 점

  • 만약 서버의 인스턴스가 여러 개가 된다면, 모든 서버끼리 같은 세션을 공유해야 하므로 세션 전용 데이터베이스를 만들어야 할 뿐 아니라 신경 써야 할 것도 많다.

23-1-1-2. 토큰 기반 인증 시스템

토큰 : 로그인 이후 서버가 만들어 주는 문자열

  • 해당 문자열 안에는 사용자의 로그인 정보가 들어 있고, 해당 정보가 서버에서 발급 되었음을 증명하는 서명이 들어 있다.

    • 서명 데이터는 해싱 알고리즘으로 통해 만들어지는데,

      주로 HMAC SHA256 또는 RSA SHA256 알고리즘이 사용

서버에서 만들어준 토큰은 무결성이 보장된다.

  • 무결성 : 정보가 변경되거나 위조되지 않았음을 의미하는 성질
  1. 사용자가 로그인을 하면
  2. 서버에서 사용자에게 해당 사용자의 정보를 지니고 있는 토큰을 발급
  3. 추후 사용자가 다른 API 요청하게될 때 발급받은 토큰과 함께 요청
  4. 서버는 해당 토큰이 유효한지 검사
  5. 결과에 따라 작업을 처리하고 응답

장점 : 서버에서 사용자 로그인 정보를 기억하기 위해 사용하는 리소스가 적다.

  • 사용자 쪽에서 로그인 상태를 지닌 토큰을 가지고 있으므로, 서버의 확장성이 매우 높다. 서버의 인스턴스가 여러 개로 늘어나도 서버끼리 사용자의 로그인 상태를 공유하고 있을 필요가 없다.

이 책에서는 토큰 기반 인증 시스템으로 진행

실습 흐름

  1. User 스키마 / 모델 만들기
  2. 회원 인증 API 만들기
  3. 토큰 발급 및 검즘하기
  4. posts API에 회원 인증 시스템 도입하기
  5. username / tags로 포스트 필터링하기

23-2. User 스키마 / 모델 만들기

User 스키마와 모델을 작성하여 사용자의 정보를 MongoDB에 담고 조회한다. 앞으로 만들 사용자 스키마에는 계정명과 비밀번호가 필요하다.

비밀번호를 데이터베이스에 저장할 때 가공 되지 않은 쌩 텍스트로 저장하면 보안상 엄청 위험하다. ( 가공되지 않은 텍스트를 : 플레인 텍스트라고 함 ) 그래서 단방향 해싱 함수를 지원해 주는 bcrypt라는 라이브러리를 사용하여 안전하게 저장하려고 한다.

일단 models 디렉토리에 user.js를 생성하고 작성한다.

import mongoose, { Schema } from 'mongoose';

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

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

export default User;

해시를 만드는 함수와 해시를 검증하는 함수를 만든다. 먼저

yarn add bcrypt

설치

23-2-1. 모델 메서드 만들기

모델 메서드는 모델에서 사용할 수 있는 함수를 의미

두가지 종류가 있다.

  • 첫 번째 - 인스턴스 메서드 : 모델을 통해 만든 문서 인스턴스에서 사용할 수 있는 함수를 의미
const user = new User({ username: 'velopert' })
user.setPassword('mypass123')
  • 두 번째 - 스태틱( static ) 메서드 : 모델에서 바로 사용할 수 있는 함수를 의미
const user = User.findByUsername('velopert')

23-2-1-1. 인스턴스 메서드 만들기

첫 번째 메서드 ( setPassword ) - 이 메서드를 통해 비밀번호를 파라미터로 받아서 계정의 hashedPassword 값을 설정

두 번째 메서드 ( checkPassword ) - 이 메서드는 파라미터로 받은 비밀번호가 해당 계정의 비밀번호와 일치하는지 검증

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 / false
};

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

export default User;

인스턴스 메서드를 작성할 땐 꼭 function 키워드로 구현해야한다. 함수 내부에서 this에 접근해야 하기 때문. 여기서 this는 문서 인스턴스를 가리킨다고 한다.

23-2-2. 스태틱 메서드 만들기

findByUsername이라는 메서드를 작성할 것이다. 이 메서드는 username으로 데이터를 찾을 수 있게 해 준다.

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 / false
};

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

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

export default User;

스태틱 함수에서 this는 모델을 가리킨다. 여기서는 User를 가리킨다고 한다.

23-3. 회원 인증 API 만들기

이제 회원 이증 API를 만든다. 새로운 라우트를 정의할 것이고, api 디렉토리에 auth 디렉토리 생성 후 그 안에 auth.ctrl.js를 작성

src/api/auth/auth.ctrl.js

export const register = async (ctx) => {
  // 회원 가입
};

export const login = async (ctx) => {
  // 로그인
};

export const check = async (ctx) => {
  // 로그인 상태 확인
};

export const logout = async (stc) => {
  // 로그아웃
};

일단 함수 틀만 잡고 auth 디렉토리에 index.js 파일을 만들어서 auth 라우터 생성

import Router from 'koa-router';
import * as authCtrl from './auth.ctrl';

const auth = new Router();

auth.post('/register', authCtrl.register);
auth.post('/login', authCtrl.login);
auth.get('/check', authCtrl.check);
auth.post('/logout', authCtrl.logout);

export default auth;

auth 라우터를 api 라우터에 적용

import Router from 'koa-router';
import posts from './posts';
import auth from './auth';

const api = new Router();

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

// 라우터를 내보냄
export default api;

이제 기능 구현시작

23-3-1. 회원가입 구현

register 함수 작성

src/api/auth/auth.ctrl.js

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

/*
  POST /api/auth/register
  {
    username: 'velopert',
    password: 'mypass123'
  }
*/

// 회원 가입
export const register = async (ctx) => {
  // Request Body 검증하기
  const schema = Joi.object().keys({
    username: Joi.string().alphanum().min(3).max(20).required(),
    password: Joi.string().required(),
  });

  const result = schema.validate(ctx.request.body);
  if (result.error) {
    ctx.status = 400;
    ctx.body = result.error;
    return;
  }

  const { username, password } = ctx.request.body;

  try {
    // username이 존재하는지 확인
    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(); // 데이터베이스에 저장

    // 응답할 데이터에서 hashedPassword 필드 제거
    const data = user.toJSON();
    delete data.hashedPassword;
    ctx.body = data;
  } catch (e) {
    ctx.throw(500, e);
  }
};

...

중복되는 계정이 생성되지 않도록 기존에 해당 username이 존재하는지 확인했다. 이 작업은 findeByUsername 스태택 메서드를 사용해 처리. 비밀번호 설정하는 과정에서는 setPassword 인스턴스 함수를 사용

API 함수 내부에서 해도 되는데 나중에 유지보수할 때 위처럼 따로 메서드를 만들어서 사용하는 것이 좋다.

마지막에는 hashedPassword 필드가 응답되지 않도록 데이터를 JSON으로 변환한 후 delete를 통해 필드를 지웠다. 앞으로 비슷한 작업을 자주 할 거라 이 작업을 serialize라는 인스턴스 함수로 따로 만든다고 한다.

src/models/user.js - serialize

UserSchema.methods.serialize = function() {
  const data = this.toJSON()
  delete data.hashedPassword
  return data
}

기존 코드 수정

const user = new User({
  username,
});

await user.setPassword(password); // 비밀번호 설정
await user.save(); // 데이터베이스에 저장

// 응답할 데이터에서 hashedPassword 필드 제거
ctx.body = user.serialize();

테스트

Compass

똑같은거 한 번 더 보내면

중복되서 에러 발생해준다.

23-2-2. 로그인 구현하기

// 로그인
export const login = async (ctx) => {
  const { username, password } = ctx.request.body;

  // username, password가 없으면 에러 처리
  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);
  }
};

위 코드는 username, password 값이 제대로 전달되지 않으면 에러처리. findByUsername을 통해 사용자 데이터를 찾고, 없으면 에러처리. 계정이 유효하다면 checkPassword를 통해 비밀번호를 검사하고 성공하면 계정 정보를 응답

비밀번호가 틀리다면

23-4. 토큰 발급 및 검증하기

클라이언트에 사용자 로그인 정보를 지니고 있을 수 있도록 서버에서 토큰을 발급해 준다. JWT 토큰을 만들기 위해서는 jsonwebtoken이라는 모듈을 설치

yarn add jsonwebtoken

23-4-1. 비밀키 설정하기

window를 사용하면 아무 문자열이나 직접 입력해도 된다고한다.

.env

PORT=4000
MONGO_URI=mongodb://localhost:27017/blog
JWT_SECRET=비밀비밀비밀

이 비밀키는 나중에 JWT 토큰의 서명을 만드는 과정에서 사용

이 비밀키는 공개되면 안된다고 한다. 아무나 와서 JWT 토큰을 발급할 수 있기 때문이라고 한다

23-4-2. 토큰 발급하기

user 모델 파일에서 generateToken이라는 인스턴스 메서드를 만든다.

...
import jwt from 'jsonwebtoken';

...

UserSchema.methods.generateToken = function () {
  const token = jwt.sign(
    // 첫 번째 파라미터에는 토큰 안에 집어넣고 싶은 데이터를 넣는다.
    {
      _id: this.id,
      username: this.username,
    },
    process.env.JWT_SECRET, // 두 번째 파라미터에는 JWT 암호를 넣는다.
    {
      expiresIn: '7d', // 7일간 유효
    },
  );
  return token;
};

...

이제 회원가입과 로그인에 성공했을 때 토큰을 사용자에게 전달해 준다. 사용자가 브라우저에서 토큰을 사용할 때는 주로 두가지 방법을 사용

  • 첫 번째 : 브라우저의 localStorage 또는 sessionStorage에 담아 사용
    • 장점 : 이렇게 하면 사용하기가 매우 편하고 구현하기 쉽다.
    • 단점 : 누군가 페이지에 악성 스크립틀르 삽입하면 쉽게 토큰을 탈취 당할 수 있음 ( XSS(Cross Site Scripting) 라고 부름 )
  • 두 번째 : 브라우저의 쿠키에 담아서 사용
    • 장점 : 쿠키에 담아도 같은 문제가 발생할 수 있지만 httpOnly라는 속성을 활성화하면 자바스크립트를 통해 쿠키를 조회할 수 없어서 악성 스크립트로부터 안전
    • 단점 : 대신 CSRF( Cross SiteRequest Forgery )라는 공격에 취약
      • 토큰을 쿠키에 담으면 사용자가 서버로 요청을 할 때마다 무조건 토큰이 함께 전달되는 점을 이용해 사용자가 모르게 원하지 않는 API 요청을 하게 만듬
      • 단 CSRF는 CSRF 토큰 사용 및 Referer 검증 등의 방식으로 막을 수 있다.

이 책은 토큰을 쿠키에 담에서 사용

register와 login 함수를 수정

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

/*
  POST /api/auth/register
  {
    username: 'velopert',
    password: 'mypass123'
  }
*/

// 회원 가입
export const register = async (ctx) => {
  // Request Body 검증하기
  const schema = Joi.object().keys({
    username: Joi.string().alphanum().min(3).max(20).required(),
    password: Joi.string().required(),
  });

  const result = schema.validate(ctx.request.body);
  if (result.error) {
    ctx.status = 400;
    ctx.body = result.error;
    return;
  }

  const { username, password } = ctx.request.body;

  try {
    // username이 존재하는지 확인
    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(); // 데이터베이스에 저장

    // 응답할 데이터에서 hashedPassword 필드 제거
    ctx.body = user.serialize();

    const token = user.generateToken();
    ctx.cookies.set('access_token', token, {
      maxAge: 1000 * 60 * 60 * 24 * 7, // 7일
      httpOnly: true,
    });
  } catch (e) {
    ctx.throw(500, e);
  }
};

/*
  POST /api/auth/login
  {
    username: 'velopert',
    password: 'mypass123'
  }
*/

// 로그인
export const login = async (ctx) => {
  const { username, password } = ctx.request.body;

  // username, password 가 없으면 에러 처리
  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();

    const token = user.generateToken();
    ctx.cookies.set('access_token', token, {
      maxAge: 1000 * 60 * 60 * 24 * 7, // 7일
      httpOnly: true,
    });
  } catch (e) {
    ctx.throw(500, e);
  }
};

export const check = async (ctx) => {
  // 로그인 상태 확인
};

export const logout = async (stc) => {
  // 로그아웃
};

23-4-3. 토큰 검증하기

이번엔 사용자의 토큰을 확인한 후 검증하는 작업을 진행

src/lib/jwtMiddleware.js

import jwt from 'jsonwebtoken';

const jwtMiddleware = (ctx, next) => {
  const token = ctx.cookies.get('access_token');

  if (!token) {
    return next(); // 토큰 없음..
  }

  try {
    const decoded = jwt.verify(token, process.env.JWT_SECRET);
    console.log(decoded);
    return next();
  } catch (e) {
    // 토큰 검증 실패
    return next();
  }
};

export default jwtMiddleware;

main.js에서 app에 미들웨어를 적용

jwtMiddleware를 적용하는 작업은 app에 router 미들웨어를 적용하기 전에 이루어져야 한다.

즉, 코드가 더욱 상단에 위치해야 한다.

require('dotenv').config();

import Koa from 'koa';
import Router from 'koa-router';
import bodyParser from 'koa-bodyparser';

import mongoose from 'mongoose';

import api from './api';
import jwtMiddleware from './lib/jwtMiddleware';

// 비구조화 할당을 통해 process.env 내부 값에 대한 레퍼런스 만들기
const { PORT, MONGO_URI } = process.env;

mongoose
  .connect(MONGO_URI, { useNewUrlParser: true, useFindAndModify: false })
  .then(() => {
    console.log('Connected to MongoDB');
  })
  .catch((e) => {
    console.error(e);
  });

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

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

// 라우터 적용 전에 bodyParser 적용
app.use(bodyParser());
app.use(jwtMiddleware);

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

// PORT가 지정되어 있지 않다면 4000을 사용
const port = PORT || 4000;
app.listen(port, () => {
  console.log(`Listening to port %d`, port);
});

미들웨어를 적용한 뒤 Postman으로 GET 요청 (http://localhost:4000/api/auth/check) 하면

Not Found에러가 뜬다. 아직 API를 구현하지 않았기 때문이다. 터미널을 확인하면 현재 토큰이 해석된 결과가 터미널에 나타난다.

이렇게 해석된 결과를 미들웨어에서 사용할 수 있게 하려면 ctx의 state 안에 넣어 주면 된다. jwtMiddleware를 수정한다.

import jwt from 'jsonwebtoken';

const jwtMiddleware = (ctx, next) => {
  const token = ctx.cookies.get('access_token');

  if (!token) {
    return next(); // 토큰 없음..
  }

  try {
    const decoded = jwt.verify(token, process.env.JWT_SECRET);
    ctx.state.user = {
      _id: decoded._id,
      username: decoded.username,
    };
    console.log(decoded);
    return next();
  } catch (e) {
    // 토큰 검증 실패
    return next();
  }
};

export default jwtMiddleware;

콘솔에 뜨는거는 토큰이 만료되기 전에 재발급해 주는 기능을 구현하고 지운다고 한다.

이제 auth.ctrl.js - check 함수를 구현

/*
  GET /api/auth/check
*/

// 로그인 상태 확인
export const check = async (ctx) => {
  const { user } = ctx.state;

  if (!user) {
    // 로그인 중 아님
    ctx.status = 401; // Unauthorized
    return;
  }
  ctx.body = user;
};

Postman으로 요청

23-4-4. 토큰 재발급하기

결과를 보면

이렇게 출력되는데

iat 값은 이 토큰이 언제 만들어졌는지

exp 값은 언제 만료되는지 알려주는 값이다.

exp에 표현된 날짜가 3.5일 미만이라면 토큰을 새로운 토큰을 재발급해 주는 기능을 구현

src/lib/jwtMiddleware.js

import jwt from 'jsonwebtoken';
import User from '../models/user';

const jwtMiddleware = async (ctx, next) => {
  const token = ctx.cookies.get('access_token');

  if (!token) {
    return next(); // 토큰 없음..
  }

  try {
    const decoded = jwt.verify(token, process.env.JWT_SECRET);
    ctx.state.user = {
      _id: decoded._id,
      username: decoded.username,
    };

    // 토큰 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, // 7일
        httpOnly: true,
      });
    }
		console.log(decoded);
    return next();
  } catch (e) {
    // 토큰 검증 실패
		console.log('실패..');
    return next();
  }
};

export default jwtMiddleware;

토큰이 재발급 되는지 궁금해서 user 모델 파일의 generateToken을 3일로 바꾸고 다시 로그인하고 check 해봤다.

확인했으니 다시 7일로 돌려놨다.

23-4-5. 로그아웃 기능 구현하기

/*
  POST /api/auth/logout
*/

// 로그아웃
export const logout = async (ctx) => {
  ctx.cookies.set('access_token');
  ctx.status = 204; // No Content
};

요거밖에 안된다.

실행해보면

access_token이 비워지는 Set-Cookie 헤더가 나타난다.

23-5. posts API에 회원 인증 시스템 도입

기존에 구현했던 posts API에 회원 인증 시스템을 도입해 본다. 새 포스트는 이제 로그인해야만 작성가능하고, 삭제와 수정은 작성자만 할 수 있도록 구현

여기서는 미들웨어를 만들어서 관리해본다. 각 포스트를 어떤 사용자가 작성했는지 알아야 하기 때문에 기존의 Post 스키마를 수정해 준다.

23-5-1. 스키마 수정하기

스키마에 사용자 정보를 넣어 준다. MongoDB에서는 필요한 데이터를 통째로 집어넣는다.

여기서는 Post 스키마 안에 사용자의 id와 username을 전부 넣어 주어야 한다.

post 모델 파일을 수정

import mongoose from 'mongoose';

const { Schema } = mongoose;

const PostSchema = new Schema({
  title: String,
  body: String,
  tags: [String], // 문자열로 이루어진 배열
  publishedDate: {
    type: Date,
    default: Date.now, // 현재 날짜를 기본값으로 지정
  },
  user: {
    _id: mongoose.Types.ObjectId,
    username: String,
  },
});

const Post = mongoose.model('Post', PostSchema);
export default Post;

23-5-2. posts 컬렉션 비우기

이제 포스트 데이터에서 사용자 정보가 필요하다. 이전에 생성한 데이터들은 더 이상 유효하지 않으므로 모두 삭제한다.

23-5-3. 로그인했을 때만 API를 사용할 수 있게 하기

lib/checkLoggedIn.js 파일을 생성해서 미들웨어를 작성한다. 로그인을 해야만 글쓰기, 수정, 삭제를 할 수 있도록 구현한다.

const checkLoggedIn = (ctx, next) => {
  if (!ctx.state.user) {
    ctx.status = 401; // Unauthorized
    return;
  }
  return next();
};

export default checkLoggedIn;

로그인 상태가 아니라면 401 HTTP Status를 반환하고, 그렇지 않으면 그 다음 미들웨어들을 실행

이제 posts 라우터에서 사용

src/api/posts/index.js

import Router from 'koa-router';
import * as postsCtrl from './posts.ctrl';
import checkLoggedIn from '../../lib/checkLoggenIn';

const posts = new Router();

posts.get('/', postsCtrl.list);
posts.post('/', checkLoggedIn, postsCtrl.write);

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

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

export default posts;

23-5-4. 포스트 작성 시 사용자 정보 넣기

포스트를 작성할 때 사용자 정보를 넣어서 데이터베이스에 저장하도록 구현

posts.ctrl.js의 write 함수를 수정

export const write = async (ctx) => {
  const schema = Joi.object().keys({
    // 객체가 다음 필드를 가지고 있음을 검증
    title: Joi.string().required(), // required()가 있으면 필수 항목
    body: Joi.string().required(),
    tags: Joi.array().items(Joi.string()).required(), // 문자열로 이루어진 배열
  });

  // 검증하고 나서 검증 실패인 경우 에러 처리
  const result = schema.validate(ctx.request.body);
  if (result.error) {
    ctx.status = 400; // bad Request
    ctx.body = result.error;
    return;
  }

  const { title, body, tags } = ctx.request.body;
  const post = new Post({
    title,
    body,
    tags,
    user: ctx.state.user,
  });
  try {
    await post.save();
    ctx.body = post;
  } catch (e) {
    ctx.throw(500, e);
  }
};

이제 Postman으로 포스트 작성 API 요청을 한다. 로그아웃을 했었으니까 다시 로그인하고 API를 요청해본다.

23-5-5. 포스트 수정 및 삭제 시 권한 확인하기

이제 마지막이다...

작성자만 포스트를 수정하거나 삭제할 수 있도록 구현. 미들웨어에서 처리하고 싶다면 id로 포스트를 조회하는 작업도 미들웨어로 해 줘야 한다. 따라서 기존에 만들었던 checkObjectId를 getPostById로 바꾸고, 해당 미들웨어에서 id로 포스트를 찾은 후 ctx.state에 담아 준다.

src/api/posts/posts.ctrl.js - getPostById( 기존 checkObjectId )

export const getPostId = 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);
  }
};

미들웨어 이름과 코드를 수정했으니까 이제 posts 라우터에도 반영

src/api/posts/index.js

import Router from 'koa-router';
import * as postsCtrl from './posts.ctrl';
import checkLoggedIn from '../../lib/checkLoggenIn';

const posts = new Router();

posts.get('/', postsCtrl.list);
posts.post('/', checkLoggedIn, postsCtrl.write);

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

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

export default posts;

다음으로 read 함수 내부에서 id로 프스트를 찾는 코드를 간소화한다.

src/api/posts/posts.ctrl.js - read

export const read = (ctx) => {
  ctx.body = ctx.state.post;
};

getPostById를 구현하고 적용했으니 checkOwnPost라는 미들웨어를 만들어야한다. 이 미들웨어는 id로 찾은 포스트가 로그인 중인 사용자가 작성한 포스트인지 확인해 준다. 만약 사용자의 포스트가 아니라면 403 에러를 발생

src/api/posts/posts.ctrl.js - checkOwnPost

export const checkOwnPost = (ctx, next) => {
  const { user, post } = ctx.state;

  if (post.user._id.toString() !== user._id) {
    ctx.status = 403;
    return;
  }
  return next();
};

MongoDB에서 조회한 데이터의 id값을 문자열과 비교할 때는 반드시 .toString()을 해줘야한다.

이 미들웨어를 수정 및 삭제 API에 적용. checkLoggedIn 다음 미들웨어로 등록해줘야 한다.

src/api/posts/index.js

import Router from 'koa-router';
import * as postsCtrl from './posts.ctrl';
import checkLoggedIn from '../../lib/checkLoggenIn';

const posts = new Router();

posts.get('/', postsCtrl.list);
posts.post('/', checkLoggedIn, postsCtrl.write);

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

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

export default posts;

여기서 새로 계정을 만들고 그 계정을 사용하여 다른 계정으로 작성된 포스트를 삭제해 본다.

근데 여기서 403 Forbidden 에러가 나와야 된다는데 나는 404에러만 나온다.

23-6. username/tags로 포스트 필터링하기

이번엔 특정 사용자가 작성한 포스트만 조회하거나 특정 태그가 있는 포스트만 조회하는 기능을 만든다.

먼저 조금 전에 새로 만든 계정으로 프스트를 작성한다. GET /api/posts에 요청해서 두 명의 사용자가 쓴 포스트가 있는지 확인하고 포스트 목록 조회 API를 수정한다.

src/api/posts/posts.ctrl.js - list

export const list = async (ctx) => {
  // query는 문자열이기 때문에 숫자로 변환해 줘야한다.
  // 값이 주어지지 않았다면 1을 기본으로 사용
  const page = parseInt(ctx.query.page || '1', 10);

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

  const { tag, username } = ctx.query;
  // tag, username 값이 유효하면 객체 안에 넣고, 그렇지 않으면 넣지 않음
  const query = {
    ...(username ? { 'user.username': username } : {}),
    ...(tag ? { tags: tag } : {}),
  };

  try {
    const posts = await Post.find(query)
      .sort({ _id: -1 })
      .limit(10)
      .skip((page - 1) * 10)
      .lean()
      .exec();
    const postCount = await Post.countDocuments(query).exec();
    ctx.set('Last-Page', Math.ceil(postCount / 10));
    ctx.body = posts.map((post) => ({
      ...post,
      body:
        post.body.length < 200 ? post.body : `${post.body.slice(0, 200)}...`,
    }));
  } catch (e) {
    ctx.throw(500, e);
  }
};

위 코드에서 query를 생성하는게 처음봤는데

const query = {
  ...(username ? { 'user.username': username } : {}),
  ...(tag ? { tags: tag } : {}),
};

이 코드는 username 또는 tag 값이 유효할 때만 객체 안에 해당 값을 넣겠다는 것을 의미한다.

만약에

{
	username,
	tags: tag
}

이런 식으로 query 객체를 만들면 요청받을 때 username이나 tag 값이 주어지지 않는다고 한다. 이 경우는 undefined 값이 들어가게되고, mongoose는 특정 필드가 undefined인 데이터를 찾게 되고, 결국 데이터를 조회할 수 없게 된다.

이제 username , tag 쿼리 파라미터를 URL에 포함시켜 요청해본다.

좋은 웹페이지 즐겨찾기