티스토리 뷰
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);
}
};
'책 > 리다기' 카테고리의 다른 글
리다기 정리15(프론트엔드 프로젝트 : 시작 및 회원 인증 구현) (0) | 2021.02.05 |
---|---|
리다기 정리13(몽고디비) (0) | 2021.02.04 |
리다기 정리 12(백엔드 Koa) (0) | 2021.02.04 |
리다기 정리11(서버 사이드 렌더링) (0) | 2021.01.29 |
리다기 정리10(코드 스플리팅) (0) | 2021.01.28 |