23. JWT 이해

히치키치·2022년 1월 16일
1

React_Advance

목록 보기
9/9
post-thumbnail

✔ 세션 기반 인증 시스템

세션 저장소로 주로 메모리, 디스크, 데이터베이스 사용

  1. 유저 로그인
  2. 서버는 세션 저장소에 사용자 정보 조회하고 세션 id 발급
    발급된 id는 브라우저 쿠키에 저장됨
  3. 사용자가 서버로 다른 요청 보냄
  4. 서버는 세션 저장소에서 세션 조회 후 로그인 여부 결정
  5. 작업 처리하고 응답함

⭐ 번거로운 서버 확장
: 서버 인스턴스가 여러개인 경우, 모든 서버끼리 같은 세션을 공유해야 함으로 세션 전용 DB를 만들어야 함

✔ 토큰 기반 인증 시스템

토큰
로그인 이후 서버가 만들어 주는 문자열
문자열 안에는 유저 로그인 정보가 있음
해당 로그인 정보가 서버에서 발급되었음을 증명하는 서명

무결성
정보가 변경되거나 위조되지 않았음을 의미
서버에서 만들어 준 서명이 토큰에 있기 때문에 무결성 보장됨

서명 데이터
해싱 알고리즘 통해 만들어짐
주로 HMAC SHA256 및 RSA SHA256 사용

  1. 유저 로그인
  2. 서버에서 사용자 정보를 가지고 토큰 발급해줌
  3. 유저가 발급받은 토큰과 함께 다른 API로 요청
  4. 서버는 토큰의 유효성 검사 진행
  5. 서버의 유효성 검사 결과에 따라 작업 처리하고 응답

⭐ 높은 서버 확장성
: 유저가 로그인 상태를 지닌 토큰을 가짐으로 서버에서 사용자 로그인 정보를 기억하기 위해 사용하는 리소스가 적음. 서버 인스턴스가 여러 개로 늘어나도 서버끼리 사용자의 로그인 상태 공유할 필요 없음

✔ User 스키마/모델 만들기

사용자 계정명과 비밀번호로 구성된 사용자 스키마
bcrypt 라이브러리를 사용해 단방향 해싱 함수로 안전한 비밀번호 저장

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. 모델 메서드

  1. 인스턴스 메서드
    모델을 통해 만든 문서 인스턴스에서 사용할 수 있는 함수
const user=new User({username : "velopert"});
user.setPassword("mypass123");
  1. 스태틱 메서드
    모델에서 바로 사용 가능한 함수
const user=User.findByUsername("velopert");

인스턴스 메서드 작성
함수 내부에서 this(문서 인스턴스) 접근 위해 화살표 함수가 아닌 function 키워드 사용

setPassword : 파라미터로 비밀번호 받아서 계정의 hashedPassword 값 설정
checkPassword : 파라미터로 받은 비밀번호가 해당 계정의 비밀 번호와 일치하는지 검증

src/models/user.js

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

스태틱 메서드 작성
스태틱 함수에서 this는 모델임

src/models/user.js

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

✔ 회원 인증 API 만들기

API 구조 잡기
회원 인증 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) => {};

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;

1. 회원가입 구현

findByUsername 스태틱 메서드
: 회원가입 시 중복 계정 생성되지 않도록 기존 username 존재 여부 확인
setPassword 인스턴스 함수
: 비밀번호 설정
=> API 함수 내부가 아닌 메서드 만들어 사용해 높은 가독성과 유지 보수성 사용
hashedPassword 필드가 응답되지 않도록 데이터를 JSON으로 변환하고 delete 진행

src/api/auth/auth.ctrl.js

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

//회원 가입
/*
    GET /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.bulkSave(); //데이터베이스 저장

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

(...)

hashedPassword 필드가 응답되지 않도록 JSON 변환 후 해당 필드를 지우는 작업은 자주 사용됨으로 serialize 인스턴스 함수 따로 작성

src/models/user.js

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

(...)

src/api/auth/auth.ctrl.js
user.serialize() 사용해 기존 코드 수정

export const register = async (ctx) => {
  (....)
    await user.setPassword(password); //비밀번호 설정
    await user.save(); //데이터베이스 저장

    ctx.body = user.serialize();
  
  } catch (e) {
    ctx.throw(500, e);
  }
};

2. Postman과 Compass 요청/응답 확인

POST 통해 user 등록 요청 -> 응답으로 hashedPassword 필드는 제거됨 확인

Compass 통해 users DB 등록 확인 (DB에서만 hashedPassword 확인 가능)

같은 username으로 user 등록 요청시 conflict 발생

3. 로그인 구현

로그인 함수 작성

로그인 에러 처리
1. usernamepassword 제대로 전달되지 않는 경우
2. findByUsername을 통해 username으로 사용자 데이터가 조회되지 않는 경우
3. checkPassword를 통해 password가 올바른 비밀번호가 아닌 경우

src/api/auth/auth.ctrl.js

//로그인
/*
    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();
  } catch (e) {
    ctx.throw(500, e);
  }
};

4. Postman과 Compass 요청/응답 확인

등록된 username과 password로 로그인 요청 -> 로그인 응답 성공 & hashPassword 필드는 응답으로 포함되지 않음

잘못된 password로 로그인 요청시 실패 응답 출력됨

✔ 토큰 발급 및 검증

JWT 토큰 발급을 위한 jsonwebtoken 모듈 설치

yarn add jsonwebtoken

1. 비밀키 설정

JWT 토큰을 만들 때 사용할 비밀키 설정
비밀키는 아무 문자열이나 사용 가능
외부에 노출되면 누구나 JWT 토큰 발급이 가능해짐

.env

PORT=4000
MONGO_URL=mongodb://localhost:27017/blog
JWT_SECRET={아무 문자열!! 비밀임!!}

2. 토큰 발급

유저가 브라우저에서 토큰 사용하는 2가지 방법
1. 브라우저 localStorage 또는 sessionStorage 딤아 사용
편리한 사용과 구현
XSS 공격 : 페이지에 악성 스크립트 삽입 시 쉽게 토큰 탈취 가능
2. 브라우저 쿠키에 담아 사용
httpOnly 속성 활성화해 자바스크립트 통해 쿠키 조회 불가능하게 해 XSS 방지 가능
CSRF 공격 : 토큰을 쿠키에 담으면 사용자가 서버로 요청할 때마자 무조건 토큰이 함께 전달된다는 점을 이용해 사용자가 모르게 API 요청 진행

⭐CSRF 토큰 사용 및 Referer 검증으로 CSRF 공격 막을 수 있음
⭐XSS 공격은 보안 장치를 적용해도 다양한 취약점 통해 공격 받을 수 있음

src/api/auth/auth.ctrl.js

//회원 가입
/*
    GET /api/auth/register
    {
        username : "velopert",
        password:"mypass123"
    }
*/
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);
  }
};

//로그인
/*
    POST /api/auth/login
    {
        username : "velopert",
        password : "mypass123"
    }
*/
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, //7일
      httpOnly: true,
    });
  } catch (e) {
    ctx.throw(500, e);
  }
};

3. Postman 요청/응답 확인

유효한 계정으로 로그인 요청했더니 응답 부분 Header에서 Set-Cookie 헤더 값이 설정된 것 확인 가능

4. 토큰 검증하기

대략적인 미들웨어 구조 작성

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;

app에 미들웨어 적용

src/main.js

(...)

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에 미들웨어 적용
app.use(jwtMiddleware);

(...)

Postman 요청/응답 확인
Postman에서는 아직 API를 구현하지 않아 Not Found 에러 발생함
터미널에 현재 토큰이 해석된 결과 나옴

jwtMiddleware에 토큰 해석 결과 사용하도록 코드 작성

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);
    ctx.state.user={
        _id: decoded._id,
        username : decoded.username,
    };
    console.log(decoded); //현재 토큰이 해석된 결과
    return next();
  } catch (e) {
    //토큰 검증 실패
    return next();
  }
};

export default jwtMiddleware;

Postman 요청/응답 확인

5. 로그아웃 기능 구현하기

src/api/auth/auth.crtl.js

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

6. Postman 요청/응답 확인

access_token이 비워짐

✔ Post API에 회원인증 도입

1. 스키마 수정 & posts 컬랙션 비우기

관계형 DB에서는 id만 관계 있는 데이터에 넣어줌
MongoDB에서는 필요한 데이터에 통째로 넣음
-> Post 스키마 안에 사용자의 id와 username 전부 넣기

src/models/post.js

import mongoose from 'mongoose';

const { Schema } = mongoose;

const PostSchema = new Schema({
(...)
  user: {
    _id: mongoose.Types.ObjectId,
    username: String,
  },
});

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

기존의 무작위로 작성한 40개의 posts 삭제

2. 로그인했을 때만 API 사용 가능하게 하기

checkedLoggedIn 미들웨어 생성해 로그인 해야만 글쓰기, 수정, 삭제 가능하도록 구현
로그인 상태 확인 작업은 자주 사용하기 때문에 lib 디렉토리에 따로 작성

src/lib/checkLoggedIn.js
로그인 상태이면 미들웨어 실행하고 아닌 경우, 401 HTTP Status 반환

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

export default checkLoggedIn;

미들웨어를 posts 라우터에서 사용 가능하도록 작성
src/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.checkObjectId, post.routes());

export default posts;

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

포스트 작성할 때 사용자 정보를 DB에 넣어 로그인 된 사용자만 포스트 작성 가능하게 함

src/api/posts/posts.ctrl.js

/*
  POST /api/posts
  {
    title : "제목",
    body : "내용",
    tags : ["태그1","태그2", "태그3"]
  }
*/
export const write = async (ctx) => {
(...)

  const { title, body, tags } = ctx.request.body;

  const post = new Post({
    //포스트 인스턴스 만들기 위해 new 키워드 사용

    title,
    body,
    tags,
    user : ctx.state.user,
    //생성자 함수 파라미터로 정보 지닌 객체 넣기
  });

  try {
(....)
  }
};

postman 요청/응답 확인

작성된 글 정보에 글을 작성한 유저의 정보까지 포함됨

4. 포스트 수정/삭제 시 권한 확인

작성자만 포스트 수정/삭제 가능하도록 해야함
미들웨어에서 id로 포스트 조회하도록 checkObjectIdgetPostById로 바꾸기
해당 id로 찾은 포스트를 ctx.state에 담아줌

src/api/posts/posts.ctrl.js

(...)
 const { ObjectId } = mongoose.Types;
console.log(ObjectId);

export const getPostById = async (ctx, next) => {
  const { id } = ctx.params;
  console.log(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

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

export default posts;
 

read 함수 : id로 포스트 찾는 코드 간소화

src/api/posts/posts.ctrl.js

(...)
/* 
  GET /api/posts/:id
*/

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

MongoDB에서 조회한 데이터 id는 .toString()을 사용해 문자열과 비교
src/api/posts/posts.ctrl.js

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

checkOwnPost를 수정/삭제 API의 미들웨어로 적용

src/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.checkOwnPost, postsCtrl.remove);
post.patch('/', checkLoggedIn, postsCtrl.checkOwnPost, postsCtrl.update);

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

export default posts;

postman 응답/요청 확인

로그인된 유저가 작성한 글이 아닌 것을 수정하려고 하는 경우 Forbidden 발생

✔ username/tags로 포스트 필터링

특정 사용자가 작성한 post 조회
특정 태그가 있는 post 조회

1. username/tags 조회 API 작성

src/api/posts/posts.ctrl.js

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로 사용하면 username 또는 tag 값이 주어지지 않고 undefined 값이 들어가면서 특정 필드가 undefined인 데이터 찾게 되며 조회 불가능 해짐

{
	username,
    tags:tag
}

3. postman 요청/응답 확인

0개의 댓글