세션 저장소로 주로 메모리, 디스크, 데이터베이스 사용
- 유저 로그인
- 서버는 세션 저장소에 사용자 정보 조회하고 세션 id 발급
발급된 id는 브라우저 쿠키에 저장됨- 사용자가 서버로 다른 요청 보냄
- 서버는 세션 저장소에서 세션 조회 후 로그인 여부 결정
- 작업 처리하고 응답함
⭐ 번거로운 서버 확장
: 서버 인스턴스가 여러개인 경우, 모든 서버끼리 같은 세션을 공유해야 함으로 세션 전용 DB를 만들어야 함
토큰
로그인 이후 서버가 만들어 주는 문자열
문자열 안에는 유저 로그인 정보가 있음
해당 로그인 정보가 서버에서 발급되었음을 증명하는 서명
무결성
정보가 변경되거나 위조되지 않았음을 의미
서버에서 만들어 준 서명이 토큰에 있기 때문에 무결성 보장됨
서명 데이터
해싱 알고리즘 통해 만들어짐
주로 HMAC SHA256 및 RSA SHA256 사용
- 유저 로그인
- 서버에서 사용자 정보를 가지고 토큰 발급해줌
- 유저가 발급받은 토큰과 함께 다른 API로 요청
- 서버는 토큰의 유효성 검사 진행
- 서버의 유효성 검사 결과에 따라 작업 처리하고 응답
⭐ 높은 서버 확장성
: 유저가 로그인 상태를 지닌 토큰을 가짐으로 서버에서 사용자 로그인 정보를 기억하기 위해 사용하는 리소스가 적음. 서버 인스턴스가 여러 개로 늘어나도 서버끼리 사용자의 로그인 상태 공유할 필요 없음
사용자 계정명과 비밀번호로 구성된 사용자 스키마
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;
//bcrypt 라이브러리 설치
yarn add bcrypt
const user=new User({username : "velopert"});
user.setPassword("mypass123");
const user=User.findByUsername("velopert");
인스턴스 메서드 작성
함수 내부에서 this(문서 인스턴스) 접근 위해 화살표 함수가 아닌 function 키워드 사용
setPassword
: 파라미터로 비밀번호 받아서 계정의 hashedPassword 값 설정
checkPassword
: 파라미터로 받은 비밀번호가 해당 계정의 비밀 번호와 일치하는지 검증
src/models/user.js
(...)
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
};
(...)
스태틱 메서드 작성
스태틱 함수에서 this는 모델임
src/models/user.js
(...)
UserSchema.statics.findByUsername = function (username) {
return this.findOne({ username });
};
(...)
API 구조 잡기
회원 인증 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) => {};
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;
auth 라우터를 api 라우터에 적용
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;
findByUsername
스태틱 메서드
: 회원가입 시 중복 계정 생성되지 않도록 기존 username 존재 여부 확인
setPassword
인스턴스 함수
: 비밀번호 설정
=> API 함수 내부가 아닌 메서드 만들어 사용해 높은 가독성과 유지 보수성 사용
hashedPassword
필드가 응답되지 않도록 데이터를 JSON으로 변환하고 delete 진행
src/api/auth/auth.ctrl.js
import Joi from 'joi';
import User from '../../models/user';
//회원 가입
/*
GET /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.bulkSave(); //데이터베이스 저장
//응답할 데이터에서 hashedPassWord 필드 제거
const data = user.toJSON();
delete data.hashedPassword;
ctx.body = data;
} catch (e) {
ctx.throw(500, e);
}
};
(...)
hashedPassword 필드가 응답되지 않도록 JSON 변환 후 해당 필드를 지우는 작업은 자주 사용됨으로 serialize
인스턴스 함수 따로 작성
src/models/user.js
(...)
UserSchema.methods.serialize = function () {
const data = this.toJSON();
delete data.hashedPassword;
return data;
};
(...)
src/api/auth/auth.ctrl.js
user.serialize() 사용해 기존 코드 수정
export const register = async (ctx) => {
(....)
await user.setPassword(password); //비밀번호 설정
await user.save(); //데이터베이스 저장
ctx.body = user.serialize();
} catch (e) {
ctx.throw(500, e);
}
};
POST 통해 user 등록 요청 -> 응답으로 hashedPassword 필드는 제거됨 확인
Compass 통해 users DB 등록 확인 (DB에서만 hashedPassword 확인 가능)
같은 username으로 user 등록 요청시 conflict 발생
로그인 함수 작성
로그인 에러 처리
1.username
과password
제대로 전달되지 않는 경우
2.findByUsername
을 통해username
으로 사용자 데이터가 조회되지 않는 경우
3.checkPassword
를 통해password
가 올바른 비밀번호가 아닌 경우
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.checkPassword(password);
//잘못된 비밀번호
if (!valid) {
ctx.status = 401;
return;
}
ctx.body = user.serialize();
} catch (e) {
ctx.throw(500, e);
}
};
등록된 username과 password로 로그인 요청 -> 로그인 응답 성공 & hashPassword 필드는 응답으로 포함되지 않음
잘못된 password로 로그인 요청시 실패 응답 출력됨
JWT 토큰 발급을 위한 jsonwebtoken 모듈 설치
yarn add jsonwebtoken
JWT 토큰을 만들 때 사용할 비밀키 설정
비밀키는 아무 문자열이나 사용 가능
외부에 노출되면 누구나 JWT 토큰 발급이 가능해짐
.env
PORT=4000
MONGO_URL=mongodb://localhost:27017/blog
JWT_SECRET={아무 문자열!! 비밀임!!}
유저가 브라우저에서 토큰 사용하는 2가지 방법
1. 브라우저 localStorage 또는 sessionStorage 딤아 사용
편리한 사용과 구현
XSS 공격 : 페이지에 악성 스크립트 삽입 시 쉽게 토큰 탈취 가능
2. 브라우저 쿠키에 담아 사용
httpOnly 속성 활성화해 자바스크립트 통해 쿠키 조회 불가능하게 해 XSS 방지 가능
CSRF 공격 : 토큰을 쿠키에 담으면 사용자가 서버로 요청할 때마자 무조건 토큰이 함께 전달된다는 점을 이용해 사용자가 모르게 API 요청 진행
⭐CSRF 토큰 사용 및 Referer 검증으로 CSRF 공격 막을 수 있음
⭐XSS 공격은 보안 장치를 적용해도 다양한 취약점 통해 공격 받을 수 있음
src/api/auth/auth.ctrl.js
//회원 가입
/*
GET /api/auth/register
{
username : "velopert",
password:"mypass123"
}
*/
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);
}
};
//로그인
/*
POST /api/auth/login
{
username : "velopert",
password : "mypass123"
}
*/
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);
}
};
유효한 계정으로 로그인 요청했더니 응답 부분 Header에서 Set-Cookie 헤더 값이 설정된 것 확인 가능
대략적인 미들웨어 구조 작성
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;
app에 미들웨어 적용
src/main.js
(...)
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에 미들웨어 적용
app.use(jwtMiddleware);
(...)
Postman 요청/응답 확인
Postman에서는 아직 API를 구현하지 않아 Not Found 에러 발생함
터미널에 현재 토큰이 해석된 결과 나옴
jwtMiddleware에 토큰 해석 결과 사용하도록 코드 작성
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;
Postman 요청/응답 확인
src/api/auth/auth.crtl.js
/*
POST /api/auth/logout
*/
export const logout = async (ctx) => {
ctx.cookies.set('access_token');
ctx.status = 204; // No Content
};
access_token이 비워짐
관계형 DB에서는 id만 관계 있는 데이터에 넣어줌
MongoDB에서는 필요한 데이터에 통째로 넣음
-> Post 스키마 안에 사용자의 id와 username 전부 넣기
src/models/post.js
import mongoose from 'mongoose';
const { Schema } = mongoose;
const PostSchema = new Schema({
(...)
user: {
_id: mongoose.Types.ObjectId,
username: String,
},
});
const Post = mongoose.model('Post', PostSchema);
export default Post;
기존의 무작위로 작성한 40개의 posts 삭제
checkedLoggedIn
미들웨어 생성해 로그인 해야만 글쓰기, 수정, 삭제 가능하도록 구현
로그인 상태 확인 작업은 자주 사용하기 때문에 lib
디렉토리에 따로 작성
src/lib/checkLoggedIn.js
로그인 상태이면 미들웨어 실행하고 아닌 경우, 401 HTTP Status 반환
const checkLoggedIn = (ctx, next) => {
if (!ctx.state.user) {
ctx.state = 401; //Unauthorized
return;
}
return next();
};
export default checkLoggedIn;
미들웨어를 posts 라우터에서 사용 가능하도록 작성
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;
포스트 작성할 때 사용자 정보를 DB에 넣어 로그인 된 사용자만 포스트 작성 가능하게 함
src/api/posts/posts.ctrl.js
/*
POST /api/posts
{
title : "제목",
body : "내용",
tags : ["태그1","태그2", "태그3"]
}
*/
export const write = async (ctx) => {
(...)
const { title, body, tags } = ctx.request.body;
const post = new Post({
//포스트 인스턴스 만들기 위해 new 키워드 사용
title,
body,
tags,
user : ctx.state.user,
//생성자 함수 파라미터로 정보 지닌 객체 넣기
});
try {
(....)
}
};
postman 요청/응답 확인
작성된 글 정보에 글을 작성한 유저의 정보까지 포함됨
작성자만 포스트 수정/삭제 가능하도록 해야함
미들웨어에서 id로 포스트 조회하도록 checkObjectId
를 getPostById
로 바꾸기
해당 id로 찾은 포스트를 ctx.state
에 담아줌
src/api/posts/posts.ctrl.js
(...)
const { ObjectId } = mongoose.Types;
console.log(ObjectId);
export const getPostById = async (ctx, next) => {
const { id } = ctx.params;
console.log(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);
}
};
(...)
posts 라우터에 반영
src/api/posts/index.js
(...)
posts.use('/:id', postsCtrl.getPostById, post.routes());
export default posts;
read 함수 : id로 포스트 찾는 코드 간소화
src/api/posts/posts.ctrl.js
(...)
/*
GET /api/posts/:id
*/
export const read = async (ctx) => {
ctx.body = ctx.state.post;
};
(...)
MongoDB에서 조회한 데이터 id는 .toString()
을 사용해 문자열과 비교
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();
};
checkOwnPost
를 수정/삭제 API의 미들웨어로 적용
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.checkOwnPost, postsCtrl.remove);
post.patch('/', checkLoggedIn, postsCtrl.checkOwnPost, postsCtrl.update);
posts.use('/:id', postsCtrl.getPostById, post.routes());
export default posts;
postman 응답/요청 확인
로그인된 유저가 작성한 글이 아닌 것을 수정하려고 하는 경우 Forbidden 발생
특정 사용자가 작성한 post 조회
특정 태그가 있는 post 조회
src/api/posts/posts.ctrl.js
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);
}
};
다음과 같은 형태의 객체를 query로 사용하면 username 또는 tag 값이 주어지지 않고 undefined 값이 들어가면서 특정 필드가 undefined인 데이터 찾게 되며 조회 불가능 해짐
{
username,
tags:tag
}