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);
}
};
'react' 카테고리의 다른 글
[리액트를 다루는 기술] 22장 mongoose를 이용한 MongoDB 연동 (0) | 2023.12.13 |
---|---|
[리액트를 다루는 기술] 21장 백엔드 프로그래밍 : Node.js의 Koa 프레임워크 (0) | 2023.12.12 |
[리액트를 다루는 기술] 19장 코드 스플리팅 (0) | 2023.12.11 |
[리액트를 다루는 기술] 18장 리덕스 미들웨어를 통한 비동기 작업 관리(2) (0) | 2023.12.11 |
[리액트를 다루는 기술] 18장 리덕스 미들웨어를 통한 비동기 작업 관리(1) (1) | 2023.12.09 |