TIL | #25 Node.js | mongoos, joi, bcrypt

trevor1107·2021년 4월 15일
1

2021-04-15(목)

joi module

joi는 정의 된 scheme를 기준으로 입력된 값의 유효성 검사를 제공해주는 모듈이다.

import Joi from 'joi';

const { ObjectId } = mongoose.Types;

export const checkObjectId = (ctx, next) => {
	const { id } = ctx.params;
	if (!ObjectId.isValid(id)) {
		ctx.status = 400; // bad request
		return;
	}
	return next();
};

// 포스트 추가
// POST /api/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(),
	});

	// 검증하고 실패인 경우 에러 처리
	// 검증을 해뒀기 때문에 하나라도 빈 값이면 write 실패
	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,
	});
	try {
		await post.save();
		ctx.body = post;
	} catch (error) {
		ctx.throw(500, error); // 500 : 클라이언트 요청 오류
	}
};

// 포스트 수정(특정 필드 변경)
// PATCH /api/posts/:id
export const update = async (ctx) => {
	const { id } = ctx.params;

	const schema = Joi.object().keys({
		// 객체가 다음 필드를 가지고 있는지 검증함
		title: Joi.string(),
		body: Joi.string(),
		tags: Joi.array().items(Joi.string()),
	});

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

	try {
		const post = await Post.findByIdAndUpdate(id, ctx.request.body, {
			new: true,
			// true : 업데이트된 데이터를 반환
			// flase : 업데이트 되기 전의 데이터를 반환
		}).exec();
		if (!post) {
			ctx.status = 404;
			return;
		}
		ctx.body = post;
	} catch (error) {
		ctx.throw(500, error);
	}
};

createFakeData

https://www.lipsum.com/ 에서 글 복사해서 body에 넣어 기본 데이터를 삽입한다

import Post from './models/post';

export default function createFakeData(){
    const posts = [...Array(40).keys()].map(i=>({
        title : `포스트 #${i}`,
        body : "At vero eos et accusamus et iusto odio dignissimos ducimus qui blanditiis praesentium voluptatum deleniti atque corrupti quos dolores et quas molestias excepturi sint occaecati cupiditate non provident, similique sunt in culpa qui officia deserunt mollitia animi, id est laborum et dolorum fuga. Et harum quidem rerum facilis est et expedita distinctio. Nam libero tempore, cum soluta nobis est eligendi optio cumque nihil impedit quo minus id quod maxime placeat facere possimus, omnis voluptas assumenda est, omnis dolor repellendus. Temporibus autem quibusdam et aut officiis debitis aut rerum necessitatibus saepe eveniet ut et voluptates repudiandae sint et molestiae non recusandae. Itaque earum rerum hic tenetur a sapiente delectus, ut aut reiciendis voluptatibus maiores alias consequatur aut perferendis doloribus asperiores repellat.",
        tags : ['안녕','좋은 아침'],
    }));
    Post.insertMany(posts,(err,docs)=>{
        console.log(docs);
    });

}

몽구스를 연결하는 곳에 추가 후 저장하여 한번만 실행하게 하고 주석처리했다.

import mongoose from 'mongoose';

mongoose
    .connect(MONGO_URI, { useNewUrlParser: true, useFindAndModify: false })
    .then(() => {
        console.log('몽고디비 오랜만이얌');
        // createFakeData(); // 데이터가 계속들어가기때문에 한번만 하기
    })
    .catch((e) => {
        console.error(e);
    });

몽구스 메서드

데이터 정렬 해서 목록 받아오기

sort 메서드는 데이터를 정렬해주는 함수이다. 원하는 프로퍼티의 설정 값으로 1은 오름차순, -1은 내림차순으로 설정할 수 있다.

아래는 _id를 기준으로 내림차순 정렬로 목록을 받아오는 구문이다.

const posts = await Post.find().sort({ _id: -1 }).exec();

갯수 제한 함수 limit()를 추가

limit 메서드는 데이터의 수를 제한하는 함수이다.

const posts = await Post.find().sort({ _id: -1 }).limit(10).exec();

쿼리 문자열과 skip()를 통해서 페이징 하기

//포스트 목록 조회
//GET /api/posts
export const list = async (ctx) => {
	// 쿼리는 문자열 => 숫자로 변환 값이 주어지지 않으면 1을 사용한다.
	// .skip 을 위한 페이징 설정
	const page = parseInt(ctx.query.page || '1', 10);

	if (page < 1) {
		ctx.status = 400;
		return;
	}
	try {
		////// sort({key:1}) // 1로 하면 오름차순 -1로 하면 내림차순
		const posts = await Post.find()
			.sort({ __id: -1 }) // 정렬
			.limit(10) // 제한
			.skip((page - 1) * 10) // 페이징 http://localhost:4000/api/posts?page=1 이렇게 입력
			.lean() // 데이터를 조회할때 JSON으로 조회한다
			.exec(); // 실행
		
		// 현재 컬럼의 갯수를 가져온다.
		const postCount = await Post.countDocuments().exec();
		// 총페이지 수 계산 (소수점있으면 올림)
		ctx.set('Last-Page', Math.ceil(postCount / 10)); 

		// ctx.body = posts;
		ctx.body = posts
			.map((post) => post.toJSON())
			.map((post) => ({
				...post,
				body: post.body.length < 200 ? post.body : `${post.body.slice(0, 200)}...`,
			}));
	} catch (error) {
		ctx.throw(500, error);
	}
};

skip 메서드는 데이터를 건너뛰는 기능을 한다. 인자로 넘어오는 number데이터를 말 그대로 skip한다.

그래서 위의 경우는 쿼리스트링 page에 따라 10개씩 보여준다.

토큰 기반 인증 시스템

토큰 : 로그인 이후 서버가 만들어 주는 문자열이다.
해당 문자열에는 사용자의 로그인 정보, 해당 정보가 서버에서 발급 되었음을 증명하는 서명이 있다.

서명 데이터 : 해싱 알고리즘을 통해서 만들어진다.

(HMAC SHA256, RSA SHA256 가 주로 쓰인다.)

서버에서 만들어준 토큰

무결성을 보장해준다. 정보가 변경되거나 위조되지 않았다는 뜻이다.

사용자 로그인 실행 순서

  1. 서버에서 사용자에게 해당 사용자 정보를 지니고 있는 토큰을 발급 한다.
  2. 추후 사용자가 다른 API요청을 하게될 때 발급받은 토큰과 함께 요청하게 된다.
  3. 서버에서는 해당 토큰에 대한 유효성을 검사한다.
  4. 결과에 따라 작업을 처리하고 응답한다.

토큰 기반 인증 시스템의 장점

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

- 사용자가 로그인 상태를 지닌 토큰을 가지고 있기 때문에 서버의 확장성이 매우 높다.

bcrypt module

암호화를 사용하기 위한 모듈이다.

yarn add bcrypt

// user.js
import mongoose, { Schema } from 'mongoose';
import bcrypt from 'bcrypt';

// setPassword, checkPassword는 인스턴스 메서드
// 모델을 통해 만든 문서 인스턴스에서 사용할 수 있는 함수이다.

// 비밀번호를 파라미터로 받아 계정의 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;
};

// 응답할 데이터에서 hashedPassword제거
UserSchema.methods.serialize = function () {
	const data = this.toJSON();
	delete data.hashedPassword;
	return data;
};

// 스태틱 메서드
// 모델에서 바로 사용할 수 있는 함수
UserSchema.statics.findByUsername = function (username) {
	return this.findOne({ username });
};
const User = mongoose.model('User', UserSchema);
export default User;
// auth.ctrl.js

// 회원 가입
/* POST /api/auth/register 
{
    username:'boo',
    password: '1234'
}
*/
export const register = async (ctx) => {
	// Requset 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;
			return;
		}
		const user = new User({
			username,
		});
		await user.setPassword(password); // 비밀번호 설정
		await user.save(); // 데이터 베이스 저장

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

// 로그인
/* POST /api/auth/login
{
    username:'boo',
    password: '1234'
}
 */
export const login = async (ctx) => {
	const { username, password } = ctx.request.body;
	if (!username || !password) {
		ctx.status = 401;
		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);
	}
};

jsonwebtoken module (JWT)

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

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

  • 브라우저의 localStorage, sessionStorage에 담아서 사용한다.
  • 브라우저의 쿠키에 담아서 사용한다

쿠키에 저장하는 방법으로 알아보자. 위에서 작성한 user.js를 이어서 진행

// user.js
import mongoose, { Schema } from 'mongoose';
import bcrypt from 'bcrypt';
import jwt from 'jsonwebtoken';

...

UserSchema.methods.generateToken = function () {
	const token = jwt.sign(
		// 첫번째 파라미터에는 토큰안에 집어 넣고 싶은 데이터를 넣는다.
		{
			_id: this.id,
			username: this.username,
		},
		// 두번째 파라미터에는 JWT암호를 넣는다
		process.env.JWT_SECRET,
		{
			expiresIn: '7d', // 7일동안 유효
		}
	);
	return token;
};
// auth.ctrl.js
// 회원가입
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);
	}
};

jwt 미들웨어를 등록해서 서버 접속시 검증하도록 해보자

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

export default jwtMiddleware;

app에 미들웨어를 등록해서 사용하면 된다. 물론 라우터 사용을 선언하기 전에 해야한다.

profile
프론트엔드 개발자

0개의 댓글