본문 바로가기

react

[리액트를 다루는 기술] 23장 JWT를 통한 회원 인증 시스템 구현하기

23.1 JWT의 이해 

 

23.1.1 세션 기반 인증과 토큰 기반 인증의 차이 

 

1) 세션 기반 인증 시스템

 

- 사용자가 로그인을 하면, 서버는 세션 저장소에 사용자의 정보를 조회하고 세션 id를 발급 

- 발급된 id는 주로 브라우저의 쿠키에 저장 

- 사용자가 다른 요청을 보낼 때마다 서버는 세션 저장소에서 세션을 조회한 후 로그인 여부를 결정하여 작업을 처리하고 응답

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

 

단점

- 서버를 확장하기가 번거로움

- 서버의 인스턴스가 여러 개가 된다면, 모든 서버끼리 같은 세션을 공유해야 하므로 세션 전용 데이터베이스를 만들어야 함 

 

 

 

2) 토큰 기반 인증 시스템

 

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

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

- 서명 데이터는 해싱 알고리즘을 통해 만들어지는데, HMAC SHA256 또는 RSA SHA256 알고리즘이 사용됨 

- 서버에서 만들어 준 토큰은 서명이 있기 때문에 무결성이 보장됨

 

- 사용자가 로그인을 하면 서버에서 사용자에게 해당 사용자의 정보를 지니고 있는 토큰을 발급해 주고, 추후 사용자가 다른 API를 요청하게 될 때 발급받은 토큰과 함께 요청하게 됨. 그러면 서버는 해당 토큰이 유효한지 검사하고, 결과에 따라 작업을 처리하고 응답 

 

- 서버에서 사용자 로그인 정보를 기억하기 위해 사용하는 리소스가 적음 

 

 

23.2 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;

 

 

23.2.1 모델 메서드 만들기 

 

모델 메서드 

- 모델에서 사용할 수 있는 함수

 

- 인스턴스 메서드 

  -> 모델을 통해 만든 문서 인스턴스에서 사용할 수 있는 함수 

ex)

const user = new User({username:'velopert'});
user.setPassword('mypass123');

 

- 스태틱 메서드 

  -> 모델에서 바로 사용할 수 있는 함수 

const user = User.findByUsername('velopert');

 

 

23.2.1.1 인스턴스 메서드 만들기 

 

- 인스턴스 메서드를 작성할 때는 화살표 함수가 아닌 function 키워드를 사용하여 구현 

- 함수 내부에서 this에 접근해야 하기 때문 

- this는 문서 인스턴스를 가리킴 

 

src/models/user.js

import mongoose, { Schema } from 'mongoose';
import bcrypt from 'bcrypt';

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

//비밀번호를 파라미터로 받아서 계정의 hashedPassword 값을 설정해줌 
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; 
}

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

 

 

23.2.2 스태틱 메서드 만들기 

 

- 스태틱 함수에서의 this는 모델을 가리킴

 

src/modles/user.js

...
//username으로 데이터를 찾을 수 있게 함 
UserSchema.statics.findByUsername = function(username) {
    return this.findOne({username});
};
...

 

 

23.3 회원 인증 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

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

 

src/api/index.js

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

const api = new Router();

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

// 라우터를 내보냅니다.
export default api;

 

 

23.3.1 회원가입 구현하기 

 

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; //Connflict
            return;
        }

        const user = new User({
            username,
        });
        await user.setPassword(password); //비밀번호 설정
        await user.save(); //데이터베이스 저장 
        ctx.body = user.serialize();
    }catch(e) {
        ctx.throw(500,e);
    }
};

 

src/models/user.js - serialize

//응답할 데이터에서 hashedPassword 필드 제거 
//hashedPassword 필드가 응답되지 않도록 데이터를 JSON으로 변환한 후 
//delete를 통해 해당 필드를 지워 줌 
UserSchema.methods.serialize = function() {
    const data = this.toJSON();
    delete data.hashedPassword;
    return data;
};

 

 

23.3.2 로그인 구현하기 

 

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.checkedPassword(password);
        //잘못된 비밀번호
        if(!valid) {
            ctx.status = 401;
            return;
        }
        ctx.body = user.serialize();
    } catch (e) {
        ctx.throw(500,e);
    }
};

 

 

23.4 토큰 발급 및 검증하기 

 

- 클라이언트에서 사용자 로그인 정보를 지니고 있을 수 있도록 서버에서 토큰을 발급 

- JWT 토큰을 만들기 위해서는 jsonwebtoken이라는 모듈 설치 

npm install jsonwebtoken

 

 

23.4.1 토큰 발급하기 

 

src/models/user.js - generateToken

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

 

 

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

 

1) 브라우저의 localStorage 혹은 sessionStorage에 담아서 사용 

 

- 구현하기 쉬움

- XSS(Cross Site Scripting)에 취약 

 

2) 브라우저의 쿠키에 담아서 사용 

 

- 쿠키에 담아도 XSS 문제가 발생할 수 있지만, httpOnly라는 속성을 활성화하면 자바스크립트를 통해 쿠키를 조회할 수 없으므로 안전

- 그 대신 CSRF(Cross Site Request Forgery) 공격에 취약 

 

CSRF(Cross Site Request Forgery)

- 토큰을 쿠키에 담으면 사용자가 서버로 요청을 할 때마다 무조건 토큰이 함께 전달되는 점을 이용해서 사용자가 모르게 원하지 않는 API 요청을 하게 만듬 

 

src/api/auth/auth.ctrl.js - register,login

/*
  POST /api/auth/register
  {
    username: 'velopert',
    password: 'mypass123'
  }
*/
export const register = async ctx => {
 ...
        await user.setPassword(password); //비밀번호 설정
        await user.save(); //데이터베이스 저장 
        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();
        ctx.cookies.set('access_token', token, {
            maxAge: 1000*60*60*24*7, //7일
            httpOnly: true,
        });
       
    } catch (e) {
        ctx.throw(500,e);
    }
};

 

 

23.4.2 토큰 검증하기 

 

- 사용자의 토큰을 확인하고 검증하는 작업

 

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;

 

src/main.js

- 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';
...

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

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

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

...

 

 

src/api/auth/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;
};

 

 

23.4.3 토큰 재발급하기 

 

iat - 이 토큰이 언제 만들어졌는지 알려 주는 값

exp - 언제 만료되는지 알려 주는 값 

 

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

 

src/lib/jwtMiddleware.js

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

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);
        //토큰의 남은 유효 기간이 3.5일 미만이면 재발급
        const now = Math.floor(Date.now() / 1000);
        if(decoded.exp - now < 60*60*24*3.5) {
            const user = await User.findById(decode._id);
            const token = user.generateToken();
            ctx.cookies.set('access_token', token, {
                maxAge:1000*60*60*24*7, //7일
                httpOnly:true,
            });
        }
        return next();
    }catch(e) {
        //토큰 검증 실패 
        return next();
    }
};

export default jwtMiddleware;

 

 

23.4.4 로그아웃 기능 구현하기 

 

src/api/auth/auth.ctrl.js - logout

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

 

 

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

 

- 새 포스트는 이제 로그인해야만 작성할 수 있고, 삭제와 수정은 작성자만 할 수 있도록 구현 

- 각각의 함수를 직접 수정해서 이 기능을 구현해도 되지만, 여기서는 미들웨어를 만들어서 관리 

 

23.5.1 스키마 수정하기 

 

- 스키마에 사용자 정보를 넣어줌 

- Post 스키마 안에 사용자의 id와 username을 전부 넣어 주어야 함 

 

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

 

 

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

 

src/lib/checkLoggedIn.js

- 로그인해야만 글쓰기, 수정, 삭제를 할 수 있도록 구현 

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

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

export default checkLoggedIn;

 

 

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;

 

 

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

 

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

 

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

export const write = async ctx => {
	...
  
    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);
    }
  };

 

 

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

 

- 작성자만 포스트를 수정하거나 삭제할 수 있도록 구현

- 이 작업을 미들웨어에서 처리하고 싶다면 id로 포스트를 조회하는 작업도 미들웨어로 해줘야 함

- 기존에 만들었던 checkObjectId를 getPostById로 바꾸고, 해당 미들웨어에서 id로 포스트를 찾은 후 ctx.status에 담아 줌 

 

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

//요청 검증 미들웨어
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.status.post = post;
      return next();
    }catch(e) {
      ctx.throw(500,e);
    }
  };

 

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

- id로 포스트를 찾는 코드를 간소화 

export const read = async ctx => {
    ctx.body - ctx.status.post;
};

 

 

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

- id로 찾은 포스트가 로그인 중인 사용자가 작성한 포스트인지 확인 

- 만약 사용자가 작성한 포스트인지 확인 

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

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

 

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

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

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

export default posts;

 

 

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

 

- 특정 사용자가 작성한 포스트만 조회하거나 특정 태그가 있는 포스트만 조회하는 기능 

 

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

- username 혹은 tag값이 유효할 때만 객체 안에 해당 값을 넣음

/*
  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? {tages: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);
    }
  };