WEB🔨/React

리액트를 다루는 기술 요약#14

최문경 블로그 2021. 7. 12. 16:50

해당 시리즈는 제목 그대로 김민준님의 '리액트를 다루는 기술'을 요약한 글입니다.

 

목차

23장 JWT를 통한 회원 인증 시스템 구현하기

 

 

23장 JWT를 통한 회원 인증 시스템 구현하기

JWT의 이해

JWT는 JSON Web Token의 약자로, 데이터가 JSON으로 이루어져 있는 토큰을 의미함.

 

인증 방식에는 대표적으로 세션 기반 인증과 토큰 기반 인증이 존재함.

 

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

 

토큰 기반은 로그인 이후 서버가 만들어 주는 문자열(토큰)(로그인 정보, 서버에서 발급되었음을 증명하는 서명 포함)을 사용자가 가지고 있는 것. 따라서 서버의 인스턴스가 여러 개로 늘어나도 서버끼리 사용자의 로그인 상태를 공유하고 있을 필요가 없기 때문에 확장성이 매우 높음. (무조건 토큰 기반이 좋은 것은 아님)

 

이 책에서는 토큰 기반 인증 시스템을 사용.

 

 

User 스키마/모델 만들기

// src/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;

 

비밀번호를 데이터베이스에 저장할 때 플레인 텍스트로 저장하면 보안상 매우 위험함.

따라서 단방향 해싱 함수를 지원해주는 bcrypt라는 라이브러리를 사용하여 비밀번호를 안전하게 저장해보자.

 

yarn add bcrypt

 

모델에서 사용할 수 있는 함수(모델 메서드)는 두 가지 종류가 있음.

  1. 인스턴스 메서드: 문서 인스턴스에서 사용
  2. 스태틱 메서드: 모델에서 바로 사용
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;
};

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

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

함수 내부에서 this에 접근해야 하기 때문에 인스턴스 메서드를 작성할 때는 function 키워드를 사용해 구현해야 함.

 

 

회원 인증 API 만들기

// 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 (ctx) => {
    // 로그아웃
};
// src/api/auth/index.js
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;
// src/api/index.js
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;

 

 

회원가입 구현하기

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

/*
  POST /api/auth/register
  {
    username: 'velopert',
    password: 'mypass1234'
  }
*/
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 = schema.validate(ctx.request.body);
  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();
  } catch (e) {
    ctx.throw(500, e);
  }
};
UserSchema.methods.serialize = function () {
  const data = this.toJSON();
  delete data.hashedPassword;
  return data;
};

hashedPassword 필드가 응답하지 않도록 데이터를 JSON으로 변환한 후 delete를 통해 해당 필드를 제거.

 

 

로그인 구현하기

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

 

 

토큰 발급 및 검증하기

yarn add jsonwebtoken

 

비밀키 설정하기

openssl rand -hex 64

PORT=4000
MONGO_URI=mongodb://localhost:27017/blog
JWT_SECRET=660516505524b68ea5e0706f6d5836d2fab368f8f427e4ff3a9806527fe598de27ada993fa7f44bc65438b9d93c5f962c5b7246715c33ed7e6f6249f539c7a10

 

 

토큰 발급하기

UserSchema.methods.generateToken = function () {
  const token = jwt.sign(
    {
      _id: this.id,
      username: this.username,
    },
    process.env.JWT_SECRET,
    { expiresIn: '7d' },
  );
  return token;
};

첫 번째 파라미터에는 토큰 안에 넣고 싶은 데이터를 넣음.

두 번째 파라미터에는 JWT 암호를 넣음.

 

사용자가 브라우저에서 토큰을 사용할 때는 주로 두 가지 방법 사용.

브라우저의 localStoorage 혹은 sessionStorage에 담아서 사용하거나 브라우저의 쿠키에 담아서 사용.

책에서는 쿠키에 담아서 사용.

 

export const register = async (ctx) => {
( ... )
    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 login = async (ctx) => {
( ... )
    ctx.body = user.serialize();
    
    const token = user.generateToken();
    ctx.cookies.set('access_token', token, {
      maxAge: 1000 * 60 * 60 * 24 * 7,
      httpOnly: true,
    });
  } catch (e) {
    ctx.throw(500, e);
  }
};

 

 

토큰 검증하기

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,
    };
    const now = Math.floor(Date.now() / 1000);
    // 토큰의 남은 유효 기간이 3.5일 미만이면 재발급
    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,
        httpOnly: true,
      });
    }
    return next();
  } catch (e) {
    return next();
  }
};

export default jwtMiddleware;

ctx의 state안에 넣어 주면 해석된 결과를 이후 미들웨어에서 사용할 수 있음.

 

 

app에 미들웨어 적용하기.

// src/main.js
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';

( ... )

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

( ... )

 

 

check

export const check = async (ctx) => {
  const { user } = ctx.state;
  if (!user) {
    ctx.status = 401;
    return;
  }
  ctx.body = user;
};

 

 

logout

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

 

 

posts API에 회원 인증 시스템 도입하기

로그인해야만 포스트를 수정, 삭제, 작성할 수 있게 구현해보자.

 

 

스키마 수정

import mongoose from 'mongoose';

const { Schema } = mongoose;

const PostSchema = new Schema({
  title: String,
  body: String,
  tags: [String],
  publishedData: {
    type: Date,
    default: Date.now,
  },
  user: {
    _id: mongoose.Types.ObjectId,
    username: String,
  },
});

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

 

 

로그인했을 때만 API 사용할 수 있게

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

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

const posts = new Router();

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

const post = new Router();

post.get('/', postsCtrl.read);
post.delete('/', checkLoggedIn, postsCtrl.remove);
post.patch('/', checkLoggedIn, postsCtrl.update);

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

export default posts;

 

 

포스트 작성 시 사용자 정보 넣기

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 = schema.validate(ctx.request.body);
  if (result.error) {
    ctx.status = 400;
    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);
  }
};

 

 

포스트 수정 및 삭제 시 권한 확인하기

// checkObjectId를 getPostById로 바꾸기
export const getPostById = async (ctx, next) => {
  const { id } = ctx.params;
  if (!ObjectId.isValid(id)) {
    ctx.status = 400;
    return;
  }
  // 해당 id의 post를 state에 넣기
  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);
  }
};

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

export const checkOwnPost = (ctx, next) => {
  const { user, post } = ctx.state;
  if (post.user._id.toString() !== user._id) {
    ctx.status = 403;
    return;
  }
  return next();
};
( ... )
posts.use('/:id', postsCtrl.getPostById, post.routes());

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

const posts = new Router();

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

const post = new Router();

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?username=&tag=&page=
*/
export const list = async (ctx) => {
  const page = +(ctx.query.page || '1');

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

  const { tag, username } = ctx.query;
  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);
  }
};

 

 

정리

앞으로 Koa를 통해 백엔드 개발을 할 때는 이렇게 미들웨어를 자주 만들어 가면서 개발하는 방법을 추천합니다. 이로써 코드의 가독성과 재사용성이 모두 높아져서 유지 보수가 쉬워질 것입니다.