티스토리 뷰

728x90
SMALL

JWT(JSON Web Token) : 데이터가 JSON으로 이루어져 있는 토큰

 

로그인을 서버에서 처리하는 데 사용할 수 있는 대표적인 두 가지 인증 방식

1. 세션을 기반으로 인증 2. 토큰을 기반으로 인증

 

User 스키마/모델 만들기

yarn add bcrypt

 

src/models/user.js

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;

회원 인증 API 만들기

회원가입

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.stats = 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(); // 데이터베이스에 저장
    ctx.body = user.serialize();
    // 응답할 데이터에서 hashedPassword 필드 제거
    //const data = user.toJSON();
    //delete data.hashedPassword;
    //ctx.body = data;
  } catch (e) {
    ctx.throw(500, e);
  }
};

 

리팩토링

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

 

로그인

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

 

토큰 발급 및 검증하기

yarn add jsonwebtoken

 

비밀키 설정하기

openssl rand -hex 64 -- 랜덤 문자열. macOS/리눅스에서(윈도우도 git bash에서 가능)

 

비밀키 만듬. 문자열 길이 자유

.env

JWT_SECRET=24d5adaef013189ac2c52e43f4fee5229af63878387452e57afb709967f3d9e5d143752b657965eddcd39a4df6662ad2bde99971323292050a58c91d977077ae

 

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에 담아서 사용하는 방법이고, 두 번째는 브라우저의 쿠키에 담아서 사용하는 방법입니다.

브라우저의 localStorage 혹은 sessionStorage에 토큰을 담으면 사용자가 매우 편리하고 구현하기도 쉽습니다. 하지만 쉽게 토큰을 탈취할 수 있습니다.(XSS(Cross Site Scripting) 공격)

쿠키에 담아도 문제가 발생할 수 있'지만, httpOnly라는 속성을 활성화하면 자바스크립트를 통해 쿠키를 조회할 수 없으므로 악성 스크립트로부터 안전합니다. 그 대신 CSRF(Cross Site Request Forgery)라는 공격에 취약해질 수 있습니다.

여기서는 쿠키 사용

auth.ctrl.js -register,login

    ctx.body = user.serialize();

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

 

토큰 검증하기

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);
    // iat는 언제 만들어졌는지, exp는 언제 만료되는지
    return next();
  } catch (e) {
    // 토큰 검증 실패
    return next();
  }
};

export default jwtMiddleware;

 

main.js

app.use(jwtMiddleware);

 

check

export const check = async (ctx) => {
  // 로그인 상태 확인
  const { user } = ctx.state;
  if (!user) {
    // 로그인 중 아님
    ctx.status = 401; // Unauthorized
    return;
  }
  ctx.body = user;
};

 

토큰 재발급하기

iat는 토큰이 언제 만들어졌는지, exp는 언제 만료되는지

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

 

jwtMiddleware.js

    // console.log(decoded);
    // 토큰의 남은 유효 기간이 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,
      });
    }

 

로그아웃

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

 

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

관계형 데이터베이스에서는 데이터의 id만 관계 있는 데이터에 넣어 주는 반면, 몽고디비에서는 필요한 데이터를 통째로 집어 넣습니다.

 

models/post.js

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;

 

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

lib/checkLoggedIn.js

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

export default checkLoggedIn;

 

api/posts/index.js

이것도 리팩토링 할수는 있어. 하면 더 복잡해서 그렇지

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(); // /api/posts/:id
post.get('/', postsCtrl.read);
post.delete('/', checkLoggedIn, postsCtrl.remove);
post.patch('/', checkLoggedIn, postsCtrl.update);

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

export default posts;

 

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

api/posts/posts.ctrl.js --write

  const post = new Post({
    title,
    body,
    tags,
    user: ctx.state.user,
  });

 

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

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

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

 

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

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

 

아래 미들웨어 : id로 찾은 포스트가 로그인 중인 사용자가 작성한 포스트 인지 확인

export const checkOwnPost = (ctx, next) => {
  const { user, post } = ctx.state;
  if (post.user._id.toString() !== user._id) {
    ctx.status = 403;
    return;
  }
  return next();
};

 

username/tags로 포스트 필터링하기

이거는 리액트처럼 qs안하고 그냥 해도 객체로 잘 나오네

ex)http://localhost:4000/api/posts?username=velopert&tag=태그1

/*
  GET /api/posts?username=&tag=&page=
*/
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.toJSON())
      .map((post) => ({
        ...post,
        body:
          post.body.length < 200 ? post.body : `${post.body.slice(0, 200)}...`,
      }));
  } catch (e) {
    ctx.throw(500, e);
  }
};

 

 

 

728x90
LIST
댓글
공지사항