Today I Learned ... react.js
🙋♂️ Reference Book
🙋 My Dev Blog
CH 23. JWT를 통한 회원 인증 시스템 구현
- JWT (JSON Web Token)
- posts API에 회원인증 시스템 구현
= JSON Web Token.
토큰 = 로그인 이후 서버가 만들어주는 문자열
토큰 안에는 사용자의 로그인 정보가 들어있고, 해당 정보가 서버에서 발급되었다는 서명이 들어있음.
서명 데이터는 해싱(Hashing) 알고리즘을 통해 만들어짐.
🙋♂️ 해싱 함수
임의의 길이의 데이터를 고정된 길이의 데이터로 매핑하는 함수이다.
해시 함수에 의해 얻어지는 값은 해시 값, 해시 코드, 해시 체크섬 또는 간단하게 해시라고 한다.
bcrypt
라이브러리로 안전하게 저장.단, 비밀번호는 mongoDB에 저장되기 전에 (save함수로 저장) 해싱 처리가 되어야 함.
import mongoose, { Schema } from 'mongoose';
// 스키마 생성
const UserSchema = new Schema({
username: String,
hashedPassword: String,
});
// 모델 생성
const User = mongoose.model('User', UserSchema);
export default User;
$ yarn add bcrypt
🙋♀️ 모델 메서드란?
- 모델에서 사용할 수 있는 함수.
const user = new User({username: 'yjin'});
user.setPassword('1234'); // user은 문서 인스턴스 = new Model({doc})
const user = User.findByUsername('yjin');
models/user.js 수정
import mongoose, { Schema } from 'mongoose';
import bcrypt from 'bcrypt';
// 스키마 생성
const UserSchema = new Schema({
username: String,
hashedPassword: String,
});
// 🔻 인스턴스 메서드 작성 시 화살표 함수는 ❌ (this 바인딩 때문)
UserSchema.methods.setPassword = async function (password) {
const hash = await bcrypt.hash(password, 10);
this.hashedPassword = hash; // 여기서 this는 문서 인스턴스를 가리킴.
};
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;
-> 둘다 async/await 함수로 작성해줌.
hash()
함수와 compare
함수hash(플레인 텍스트, salt)
-> 숫자 10은 salt로, 높을 수록 암호화 연산이 증가. (하지만 암호화하는데 속도가 느려짐)
compare(플레인 텍스트, 해시값)
username
으로 특정 데이터를 찾게 해줌.models/user.js 수정
UserSchema.statics.findByUsername = function (username) {
return this.findOne({ username }); // 👈 username: username인 문서를 찾기.
};
-> 스태틱 함수에서의 this는 모델을 가리킴. (=User)
📌 Model.findOne()
- 조건을 만족하는 문서를 찾아줌.
- 만약 문서의 특정 필드만 보고싶다면 두번째 인자로 넣어줌.
- 공식 매뉴얼 참고
auth.ctrl.js
파일을 먼저 만든다.auth.ctrl.js
export const register = async (ctx) => {
// 회원 가입 (등록)
};
export const login = async (ctx) => {
// 로그인
};
export const check = async (ctx) => {
// 로그인 상태 확인
};
export const logout = async (ctx) => {
// 로그아웃
};
index.js
를 생성해준다.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
라우터를 불러와 적용한다.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;
src/api/auth/auth.ctrl.js 수정
-> register API
// 1. 회원 가입 (등록)
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(); // DB에 저장
const data = user.toJSON();
delete data.hashedPassword;
ctx.body = data;
} catch (e) {
ctx.throw(500, e);
}
};
Joi 라이브러리로 검증
-> schema 객체 생성 (데이터타입 유효성을 담은 객체)
-> schema.validate(ctx.request.body)로 검사함
-> result.error 필드가 존재하면 에러가 있는 것. -> 400 에러 발생시킴
username 이미 존재하는지 검사
-> User 모델의 스태틱 메서드인 findByUsername이 true면 이미 존재하는 아이디임.
-> 409 에러 발생시킴
user 문서 인스턴스 생성
-> new Model({문서}) 해서 문서 인스턴스인 user
만듬.
-> user.setPassword로 인스턴스 메서드 사용.
-> this.hashedPassword를 설정하는 메서드임. (해시처리해서)
DB에 등록 - user.save()
ctx.body에 응답할 데이터에서 비밀번호는 제외해야 함.
-> user 객체를 수정하려면 user.toJSON()을 해서 JSON 으로 바꾼 뒤에 수정해야함.
-> 객체의 특정 필드를 제거하려면 delte 키워드를 사용함.
-> 추후에도 사용해야 하니, models/user.js
에 인스턴스 메서드로 추가해주자.
UserSchema.methods.serialize = function () {
const data = this.toJSON();
delete data.hashedPassword;
return data;
};
http://localhost:4000/api/auth/register 에 POST 요청을 한다.
request body는 위와 같이 username과 password를 입력한다.
-> 반드시 response body에는 password가 제외되어 있어야 함.
users
컬렉션이 추가되었다.+) 만약, 같은 username으로 한번 더 register(POST)을 요청하면 409 에러가 발생한다.
auth.ctrl.js 수정
-> login API
// 2. 로그인
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);
// username이 존재하지 않으면 에러
if (!user) {
ctx.status = 401;
return;
}
const valid = await user.checkPassword(password);
// password 불일치시 에러
if (!valid) {
ctx.staus = 401;
return;
}
ctx.body = user.serialize();
} catch (e) {
ctx.throw(500, e);
}
};
serialize()
메서드
- 표준 URL 인코딩 표기법으로 텍스트 문자열을 생성함.
username, password값이 없으면 에러처리
스태틱 메서드인 findByUsername을 통해 사용자 데이터를 찾음
-> 만약 없으면 에러처리
인스턴스 메서드인 checkPassword로 비밀번호 일치 여부 확인
-> bcrypt.compare로 플레인 텍스트(=password)와 해시값(=hashedPassword)을 비교
-> 일치하면 true 반환
-> 아닌경우 401 에러 발생
user.serialize()를 한 후 응답함.
-> hashedPassword 필드를 제외시킴.
-> http://localhost:4000/api/auth/login POST 요청.
-> request body는 위와 같이 작성.
-> 만약 틀린 비밀번호라면 위와 같이 401 에러 발생.
jsonwebtoken
모듈이 필요.$ yarn add jsonwebtoken
.env
파일을 열어서 JWT 토큰 생성시 사용할 비밀키를 만듬.TIP - 터미널에 다음 명령어를 치면 랜덤 문자열을 만들어줌.
$ openssl rand -hex 64
랜덤 값을 복사해 .env 파일에서 JWT_SECRET 값으로 설정.
비밀키는 외부에 공개되면 누구든지 맘대로 JWT 토큰을 발급할 수 있어 위험함.
generateToken
이라는 인스턴스 메서드 생성.UserSchema.methods.generateToken = function () {
const token = jwt.sign(
{
_id: this.id,
username: this.username,
},
process.env.JWT_SECRET,
{
expiresIn: '7d',
},
);
return token;
};
✅ 참고 - jwt(jsonwebtoken)의 함수
- jwt.sign = 토큰 발급
jwt.sign(payload, secretKey, options);
- jwt.verify = 토큰 인증(확인)
jwt.verify(token, secretKey);
auth.ctrl.js 수정
-> register, login API
const token = user.generateToken();
ctx.cookies.set('access_token', token, {
maxAge: 1000 * 60 * 60 * 24 * 7, // 7d
httpOnly: true,
});
-> ctx.body = user.serialize()
아래줄에 적어줌.
(즉, try문의 최하단에 적어줌)
cookies.set() 메서드
- mdn문서
- 첫번째 인자로는 쿠키명(=이름)이 들어가고
- 두번째 인자로는 넣어줄 데이터가 들어가고
(여기서는 user.generateToken(), 즉 jwt.sign()으로 만들어진 토큰이 들어감)- 세번째 인자로는 details들이 들어감. (maxAge는 최대 유효기간)
Set-cookie
라는 헤더가 보임.+) 좌측 Cookies 탭 에서 access_token 이라는 이름의 쿠키가 생성된 것을 알 수 있음.
jwtMiddleware.js
파일을 생성.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;
import jwtMiddleware from './lib/jwtMiddleware';
...
// 라우터 적용 전에 미들웨어를 적용해야 함
app.use(bodyParser());
// 🔻 추가
app.use(jwtMiddleware);
...
/api/auth/check
경로에 GET 요청을 함.iat : 이 토큰이 언제 생성되었는지.
exp: 이 토큰이 언제 만료되는지.
console.log(decoded)
에 의해 jwt.verify()
의 결과가 출력됨.🔺 참고 -
jwt.verify
jwt.verify(token, secretKey);
- 참고로 Joi 라이브러리의 validate 함수(=검증) 과는 다르니 잘 구분하자.
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;
auth.ctrl.js
의 check API 구현// 3. 로그인 상태 확인
export const check = async (ctx) => {
const { user } = ctx.state;
// 로그인중이 아닐 때
if (!user) {
ctx.status = 401;
return;
}
ctx.body = user;
};
jwtMiddleware에서 jwt.verify()
된 값, 즉 decoded
에서 exp는 만료일을 알려주는 값이였다.
만약 exp가 3.5일 미만이라면, 토큰을 새롭게 재발급해주는 기능을 구현해보자.
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,
};
// 🔻 추가 - exp가 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, // 7d
httpOnly: true,
});
}
return next();
} catch (e) {
return next();
}
};
export default jwtMiddleware;
❗️ 왜 Math.floor(Date.now() / 1000)를 했나?
- ms가 아닌 초단위로 보기 위해
->exp: 1653366898
와 같이 sec 단위임! 단위 통일시켜줘야함.
auth.ctrl.js 수정
// 4. 로그아웃
export const logout = async (ctx) => {
ctx.cookies.set('access_token');
ctx.status = 204;
};
마지막으로,
확인을 위해 http://localhost:4000/api/auth/logout 로 POST 요청을 해보자.
다음 포스팅
- posts API에 회원인증 시스템 도입
- username / tags로 포스트 필터링