yarn add bcrypt
2) 스키마 정의하기
3) 컬렉션 이름과 2)에서 정의한 스키마를 mongoose에 연동시키기.
(이때 컬렌션 이름은 복수형이 된다)
(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;
const user = new User({username: 'velopert'});
user.setPassword('mypass123');
1) 비밀번호 세팅 메서드 만들기
(src/models/user.js)
import mongoose, { Schema } from 'mongoose';
import bcrypt from 'bcrypt';
(..)
UserSchema.methods.setPassword = async function(password) {
const hash = await bcrypt.hash(password, 10);
this.hashedPassword = hash;
};
2) 비밀번호 체크 메서드 만들기
UserSchema.methods.checkPassword = async function(password) {
const result = await bcrypt.compare(password, this.hashedPassword);
return result; // true / false
};
const user = User.findByUsername('velopert');
1) 유저이름 찾기
UserSchema.statics.findByUsername = function(username) {
return this.findOne({ username });
};
export const register = async ctx => {
// 회원가입
};
export const login = async ctx => {
// 로그인
};
export const check = async ctx => {
// 로그인 상태 확인
};
export const logout = async ctx => {
// 로그아웃
};
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;
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';
2) register 컨트롤러 함수 만들기
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;
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();
const data = user.toJSON();
delete data.hashedPassword();
ctx.body=data;
catch (e) {
ctx.throw(500, e);
}
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; // Conflict
return;
}
const user = new User({
username,
});
await user.setPassword(password); // 비밀번호 설정
await user.save(); // 데이터베이스에 저장
// 응답할 데이터에서 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;
};
(src/api/auth/auth.ctrl.js 기존 코드 대체)
export const register = async ctx => {
(...)
const user = new User({
username,
});
await user.setPassword(password); // 비밀번호 설정
await user.save(); // 데이터베이스에 저장
ctx.body = user.serialize();
} catch (e) {
ctx.throw(500, e);
}
};
0) 비동기는 필수
1) ctx.request.body에서 username 과 password 빼오기
export const login = async ctx => {
const { username, password } = ctx.request.body;
2) 만약 1에서 에러가 발생하면( = username 이나 password 가 없으면) ctx.status에 401 세팅하고 return 하기
if (!username || !password) {
ctx.status = 401; // Unauthorized
return;
}
3) 스테틱 메서드인 .findByUsername(username)으로 username 찾아 user 에 저장하기
try {
const user = await User.findByUsername(username);
4) 만약 3에서 에러가 발생하면(= username 이 없으면) ctx.status에 401 세팅하고 return 하기
if (!user) {
ctx.status = 401;
return;
}
5) 스테틱 메서드인 .checkPassword(password)로 password 찾아 valid 에 저장하기
const valid = await user.checkPassword(password);
6) 만약 5에서 에러가 발생하면(=비밀번호가 잘못되었으면) ctx.status에 401 세팅하고 return 하기
if (!valid) {
ctx.status = 401;
return;
}
7) 인스턴스 메서드인 user.serialize() (해쉬 비밀번호 제외하기) 로 해쉬 비밀번호 제외하고 ctx.body 에 user값 실어 보내기
ctx.body = user.serialize();
8) 에러 나면 ctx.status에 500 세팅하고 return 하기
catch (e) {
ctx.throw(500, e);
}
(전체파일: src/api/auth/auth.ctrl.js-login)
/*
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);
}
};
yarn add jsonwebtoken
PORT=4000
MONGO_URI=mongodb://localhost:27017/blog
JWT_SECRET=33e86ac48e993d06bb3afd382863457e6bca79daf983ec4ba783e75999b1f4a000e686223b87f05659f7f85e0d369a596d32d2df6f76c41fca708c23a7c15488
import jwt from 'jsonwebtoken';
2) 인스턴스 메서드 .generateToken 추가하기
3) token 상수 첫 번째 인자에 _id와 username이 키로 들어간 객체를 넣는다.
UserSchema.methods.generateToken = function() {
const token = jwt.sign(
// 첫 번째 파라미터에는 토큰 안에 집어넣고 싶은 데이터를 넣습니다.
{
_id: this.id,
username: this.username,
},
4) token 상수 두 번째 인자에 process.env.JWT_SECRET 키를 넣는다.
process.env.JWT_SECRET,
5) token 상수 세 번째 인자에 유효기간을 넣는다.
{
expiresIn: '7d', // 7일 동안 유효함
},
6) 마지막으로 token을 반환한다. ( 이 메서드는 토큰을 발행하는 메서드 )
return token;
};
(전체파일)
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,
},
process.env.JWT_SECRET, // 두 번째 파라미터에는 JWT 암호를 넣습니다.
{
expiresIn: '7d', // 7일 동안 유효함
},
);
return token;
};
export const register = async ctx => {
(...)
ctx.body = user.serialize();
const token = user.generateToken();
2) 쿠키를 세팅한다. 이때 첫 번째 인자는 키로 'access_token'이고, 두 번째 인자는 value로 token을 넣는다. 세 번째 인자는 유효기간을 표시하는 maxAge와 js로 토큰 조회가 불가능하게 하는 httpOnly속성을 넣는다.
ctx.cookies.set('access_token', token, {
maxAge: 1000 * 60 * 60 * 24 * 7, // 7일
httpOnly: true,
});
3) 여기서 에러가 나면 500을 던진다.
} catch (e) {
ctx.throw(500, e);
}
4) 동일한 작업을 반복한다.
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, // 7일
httpOnly: true,
});
} catch (e) {
ctx.throw(500, e);
}
};
import jwt from 'jsonwebtoken';
2) 쿠키에서 'access_token'키로 토큰을 가져온다.
const token = ctx.cookies.get('access_token');
3) 2)에서 에러가 난다(=토큰이 없다) 면 next()를 리턴한다.
if (!token) return next(); // 토큰이 없음
4) jwt.verify의 첫 번째 인자는 토큰, 두 번째 인자는 JWT_SECRET 키를 사용하여 decoded 값을 구한 뒤, next()를 리턴한다
try {
const decoded = jwt.verify(token, process.env.JWT_SECRET);
console.log(decoded);
return next();
5) 4)에서 에러 발생시 리턴한다
catch (e) {
// 토큰 검증 실패
return next();
}
(전체 파일: 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;
6) env 파일을 모듈로 사용한다
require('dotenv').config();
7) jwt 미들웨어를 가져온다
import jwtMiddleware from './lib/jwtMiddleware';
(...)
8) 미들웨어를 적용한다
app.use(jwtMiddleware);
9) app 인스턴스에 라우터를 적용한다
app.use(router.routes()).use(router.allowedMethods());
(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());
(...)
export const check = async ctx => {
const { user } = ctx.state;
2) 1) 에서 에러면 ctx.status에 401 세팅하고 return
if (!user) {
// 로그인 중 아님
ctx.status = 401; // Unauthorized
return;
}
3) ctx.body에 user값 세팅하기
ctx.body = user;
};
4) 전체 파일
(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;
};
const now = Math.floor(Date.now() / 1000);
2) token 해제한 것의 exp(유효기간) 에서 현재를 뺀 것이 3.5일 미만일 경우
if (decoded.exp - now < 60 * 60 * 24 * 3.5)
3) .findById의 인자를 해체된 토큰의 _id로 하여 찾아 user에 넣는다.
const user = await User.findById(decoded._id);
4) 이 유저값으로 다시 토큰을 생성한다
const token = user.generateToken();
5) 쿠키에 생성한 토큰을 다시 세팅한다.
ctx.cookies.set('access_token', token, {
maxAge: 1000 * 60 * 60 * 24 * 7, // 7일
httpOnly: true,
});
6) next()를 리턴한다
}
return next();
7) 실패할 경우 에러처리를 한다.
catch (e) {
// 토큰 검증 실패
return next();
}
8) 전체 파일
(src/lib/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, // 7일
httpOnly: true,
});
}
return next();
} catch (e) {
// 토큰 검증 실패
return next();
}
};
export default jwtMiddleware;
/*
POST /api/auth/logout
*/
export const logout = async ctx => {
ctx.cookies.set('access_token');
ctx.status = 204; // No Content
};
import mongoose, { Schema } from '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;
const checkLoggedIn = (ctx, next)=>{
if(!ctx.state.user){
ctx.status=401;
return;
}
return next();
}
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;
const { title, body, tags } = ctx.request.body;
2) Post 모델의 post 인스턴스 문서(아이템 하나)를 title과 body, tags, user 값을 사용하여 만들기
const post = new Post({
title,
body,
tags,
user: ctx.state.user,
});
3) post.save()로 Post 모델에 저장하기
await post.save();
4) ctx.body에 post를 담아 보내기
ctx.body = post;
5) 4에서 에러가 나면 500던지기
catch (e) {
ctx.throw(500, e);
}
6) 전체 파일
(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);
}
};
export const getPostById = async (ctx, next) => {
const { id } = ctx.params;
2) 만약 id가 올바르지 않다면 ctx.status에 400 입력하고 return 하기
if (!ObjectId.isValid(id)) {
ctx.status = 400; // Bad Request
return;
}
3) 스태틱 메서드인 .findById(id)로 Post에서 post 아이템(=인스턴스 문서) 차지
const post = await Post.findById(id);
4) 3에서 post 가 존재하지 않으면 ctx.status에 404 넣고 return 하기
if (!post) {
ctx.status = 404; // Not Found
return;
}
5) ctx.state.post에 post 넣어주고 다음 컨트롤러로 넘기기
ctx.state.post = post;
return next();
6) 3~5에서 에러가 나면 500 던지기
catch (e) {
ctx.throw(500, e);
}
};
7) 전체파일
(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.state.post = post;
return next();
} catch (e) {
ctx.throw(500, e);
}
};
8) 라우터에 반영하기
(src/api/posts/index.js)
(...)
posts.use('/:id', postsCtrl.getPostById, post.routes());
export default posts;
export const read = ctx => {
ctx.body = ctx.state.post;
};
export const checkOwnPost = (ctx, next) => {
const { user, post } = ctx.state;
2) post의 user정보의 id를 toString()으로 문자열화한 것과 user정보의 _id가 같지 않으면 ctx.status에 403을 세팅하고 return하기
if (post.user._id.toString() != = user._id) {
ctx.status = 403;
return;
}
3) next()를 리턴하기
return next();
};
4) 전체 파일
(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();
};
5) 수정 및 삭제 API 에 적용하기
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;
export const list = async ctx => {
// query는 문자열이기 때문에 숫자로 변환해 주어야 합니다.
// 값이 주어지지 않았다면 1을 기본으로 사용합니다.
const page = parseInt(ctx.query.page || '1', 10);
2) page가 1이 안 될 경우 ctx.status에 400을 넣고 return 한다
if (page < 1) {
ctx.status = 400;
return;
}
3) 이번에는 ctx.query에서 tag과 username을 꺼낸다
const { tag, username } = ctx.query;
4) username, 혹은 tag가 존재할 경우에만 각각 값을 넣어주어 찾는다.
const query = {
...(username ? { 'user.username': username } : {}),
...(tag ? { tags: tag } : {}),
};
5) 쿼리로 찾은 포스트들에 각종 제약을 걸어두어 정제한다 (역순정렬, 10개씩 보여주기, page에 따라 스킵해서 보여주기(예시: 20번째~29번째), json으로 변경 등 )
try {
const posts = await Post.find(query)
.sort({ _id: -1 })
.limit(10)
.skip((page - 1) * 10)
.lean()
.exec();
6) 스태틱 메서드인 Post.countDocuments(query)로 개수를 구한다
const postCount = await Post.countDocuments(query).exec();
7) 마지막 페이지는 postCount에 10을 나눠서 구한다
ctx.set('Last-Page', Math.ceil(postCount / 10));
8) ctx.body에는 Posts들이 들어가되, 본문을 200자 내외로 제외한 배열이 들어간다
ctx.body = posts.map(post => ({
...post,
body:
post.body.length < 200 ? post.body : `${post.body.slice(0, 200)}...`,
}));
9) 5~ 8사이의 에러는 잡아낸다
catch (e) {
ctx.throw(500, e);
}
10) 전체 파일
/*
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,
body:
post.body.length < 200 ? post.body : `${post.body.slice(0, 200)}...`,
}));
} catch (e) {
ctx.throw(500, e);
}
};