도커로 데이터 베이스 열기
password 평문인 부분에 암호화 된 정보 붙여넣기
평문일 경우 로그인이 불가능하기 때문에 암호화를 해줘야하는데 기존에 암호화된 항목을 복붙해도 된다.
토큰
토큰 발행
서버 --> 클라이언트(통행권)
토큰 검증
서버(검증) <-- 클라이언트
토큰은 세 부분으로 이루어져 있다.
- HEADER(빨간 부분)
- PAYLOAD(보라색 부분): 사용자의 정보를 담는 부분(중요)
- VERIFY SIGNATURE(하늘색 부분)
BASE64로 인코딩/디코딩 되어 있다.
토큰 정책
- 토큰 발행 시 만료 시간 : 2시간
- 토큰의 payload에는 사용자의 pk, 이름, 아이디, 권한 정보를 넣는다.
- 토큰 발행은 응답헤더의 token으로 한다.
토큰 검증
- 클라이언트에서 발송하는 토큰 정보는 요청헤더의 token으로 한다.
- 토큰 검증이 확인 되면 매번 새로운 토큰을 갱신 발급해 준다.
- 토큰 갱신은 응답헤더의 token으로 한다.
토큰 폐기 정책
서버가 클라이언트에게 주는 것, 클라이언트가 알아서 하면 된다.
근데 만약 토큰 발행 내역을 DB에 저장할 경우엔 서버에서도 처리가 필요하다.
토큰 발행
토큰에서 사용할 secretKey 생성
secretKey 생성할 땐 키 생성 전용 서비스를 이용하는 게 좋다.
생성할 시크릿 키는 64 hex characters(= 256 binary bits / 64글자)로 만들어준다.
토큰 처리 유틸 생성
쓸 항목만 넣는 게 좋다.(많이 넣을수록 토큰이 길어짐)
const jwt = require('jsonwebtoken');
const secretKey = '시크릿 키가 여기 들어감';
const options = {
expiresIn: '2h', // 만료시간 : 2시간
};
const tokenUtil = {
// 토큰 생성
makeToken(user) {
const payload = { // payload 뭘 넣을 진 정책을 따른다
id: user.id,
userid: user.userid,
name: user.name,
role: user.role,
};
const token = jwt.sign(payload, secretKey, options); // sign : 토큰 만들어줌
return token;
},
};
module.exports = tokenUtil;
로그인 처리용 프로세스
DAO
findOne : 하나만 찾아줌
attributes : 토큰 생성에서 쓴 항목
로그인 방식 : id만 추출
// 로그인을 위한 사용자 조회
selectUser(params) {
return new Promise((resolve, reject) => {
User.findOne({ // 하나만 찾아줌
attributes: ['id', 'userid', 'password', 'name', 'role'],
// 토큰 생성에서 발행한 항목
where: { userid: params.userid },
// findOne이 찾을 항목 : userid(id로 대조)
}).then((selectOne) => {
resolve(selectOne);
}).catch((err) => {
reject(err);
});
});
},
Service
틀부터 만들기
// login 프로세스
async login(params) {
// 1. 사용자 조회
const user = null;
return new Promise((resolve) => {
resolve(user);
});
},
사용자 조회부터 만들기
// login 프로세스
async login(params) {
const user = null;
// 1. 사용자 조회
try {
user = await userDao.selectUser(params);
logger.debug(`(userService.login) ${JSON.stringify(user)}`);
} catch (err) { // 기존 에러코드들과 동일
logger.error(`(userService.login) ${err.toString()}`);
return new Promise((resolve, reject) => {
reject(err);
});
}
// 2. 비밀번호 검증
return new Promise((resolve) => {
resolve(user);
});
},
근데 저렇게 하면 user가 const로 되어있어 에러가 난다.
따라서 user를 const에서 let으로 바꿔준다.
let user = null;
hash 함수에 비밀번호 확인 만들기
checkPasswordHash
// 비밀번호 확인
checkPasswordHash(password, encryptedPassword) {
// 사용자가 입력한 평문과 encryptedPassword를 비교할 것
return new Promise((resolve, reject) => {
if (!password || !encryptedPassword) {
// password나 encryptedPassword가 null 이라면
reject(new Error('Not allowed null (password)'));
}
// 1. salt와 hash 분리
const encryptedPasswordSplit = encryptedPassword.split('.');
// encryptedPassword 데이터 안의 '.'을 기준으로 분리하겠다
const salt = encryptedPasswordSplit[0];
// 0번째 인덱스는 salt
const encryptedHash = encryptedPasswordSplit[1];
// 1번째 인덱스는 encryptedHash
// 2. 입력된 password로부터 hash 생성
crypto.pbkdf2(password, salt, iterations, 64, 'sha256', (err, derivedKey) => {
if (err) throw err;
const hash = derivedKey.toString('hex');
// 입력된 password와 암호화된 password를 비교한다.
if (hash === encryptedHash) {
resolve(true);
} else {
resolve(false); // reject 아님(오류가 아님)
}
});
});
},
비밀번호 검증
Service
// 2. 비밀번호 검증
try {
const checkPassword = await hashUtil.checkPasswordHash(params.password, user.password);
logger.debug(`(userService.checkPassword) ${checkPassword}`);
// 비밀번호 틀린 경우 튕겨냄
if (!checkPassword) {
const err = new Error('incorrect userid or password');
// 보안을 위해 <id '나' password가 틀렸다> 라고 적어줌
logger.error(err.toString());
return new Promise((resolve, reject) => {
reject(err);
});
}
} catch (err) { // 기존 에러코드들과 동일
logger.error(`(userService.checkPassword) ${err.toString()}`);
return new Promise((resolve, reject) => {
reject(err);
});
}
return new Promise((resolve) => {
resolve(user);
});
},
router 처리
원래는 https 사용해야 하는데 그러려면 ssl을 구매해야 함. 따라서 그냥 진행
/routes/auth.js
계속 지금 password도 로그파일에 그대로 담기고 있는데 원래는 이렇게 하면 절대 안된다(당연).
const express = require('express');
const router = express.Router();
const logger = require('../lib/logger');
const tokenUtil = require('../lib/tokenUtil');
const userService = require('../service/userService');
// user 토큰 발행
router.post('/token', async (req, res) => {
try {
const params = {
userid: req.body.userid,
password: req.body.password,
};
logger.info(`(auth.token.params) ${JSON.stringify(params)}`);
// 원래는 이렇게 password 그대로 로그파일에 담으면 절대 안됨
// 입력값 null 체크
if (!params.userid || !params.password) {
// 여태까지 해온 코드와 동일
}
// 비즈니스 로직 호출
const result = await userService.login(params);
logger.info(`(auth.token.result) ${JSON.stringify(result)}`);
// 토큰 생성
const token = tokenUtil.makeToken(result);
res.set('token', token); // header 세팅
// 최종 응답
res.status(200).json({ token });
} catch (err) {
res.status(500).json({ err: err.toString() });
}
});
module.exports = router;
router 등록
index
const authRouter = require('./auth');
// ...
router.use('/auths', authRouter);
// ...
jwt로 decode
iat(Issued at) : 토큰 발행일
exp(Expiration time) : 토큰 만료일
=> 유닉스 시간 : 1970년 1월 1일 00:00:00 (세계협정시)부터 경과 시간을 초로 환산하여 정수로 나타낸 것
const date = new Date(1643257106) <= 이렇게 하면 연월일시분초가 찍힘
토큰 검증(middleware)
middleware : 중간 소프트웨어
토큰 검증 함수 생성
/lib/tokenUtil.js
verifyToken(프론트에서 받아온 토큰)
jwt.verify : 정상적인 경우 payload의 값 출력(decode)
// 토큰 검증
verifyToken(token) { // 프론트에서 가져온 코드
try {
const decoded = jwt.verify(token, secretKey); // 토큰 비교
return decoded; // 맞으면 decoded(payload의 값 출력)
} catch (err) {
return null;
}
},
};
미들웨어 middleware 함수 생성
위에서 생성한 토큰 검증 함수를 미들웨어를 통해 사용
request -> middleware -> response err
or -> service -> response
에러 처리
const middleware = {
// 로그인 체크
isLoggedIn(req, res, next) {
// req, res, next : 미들웨어를 쓰기 위해 필요한 3요소
// next() <- 이걸로 진행
const token = req.headers.token;
// 헤더의 토큰 정보를 가지고 오겠다
},
};
이렇게 치면 const token에서 에러가 뜬다.
따라서 아래처럼 쳐줘야 한다.
const token = req.headers && req.headers.token;
const logger = require('./logger');
const tokenUtil = require('./tokenUtil');
const middleware = {
// 로그인 체크
isLoggedIn(req, res, next) {
// req, res, next : 미들웨어를 쓰기 위해 필요한 3요소
// next() <- 이걸로 통과시켜줌
const token = req.headers && req.headers.token;
// 헤더의 토큰 정보를 가지고 오겠다
if (token) {
// 토큰이 있는 경우 토큰 검증 수행
const decoded = tokenUtil.verifyToken(token);
logger.debug(`isLoggedIn decoded ${JSON.stringify(decoded)}`); // 로그 찍어보기
if (decoded) {
// 1. 토큰 검증이 성공한 경우 새로 갱신해준다.
const newToken = tokenUtil.makeToken(decoded);
res.set('token', newToken); // header 세팅
next(); // 미들웨어 통과(계속 진행)
} else {
// 2. 토큰 검증이 실패한 경우 401 에러를 응답
const err = new Error('Unauthorized token');
logger.error(err.toString());
res.status(401).json({ err: err.toString() });
}
} else {
// 토큰이 없는 경우 401에러 응답
const err = new Error('Unauthorized token');
logger.error(err.toString());
res.status(401).json({ err: err.toString() });
}
},
};
module.exports = middleware;
토큰 폐기
토큰 폐기에 대한 별도의 후속 처리는 없기 때문에 백엔드를 만들지 않아도 된다.
(아까 적었듯 프론트엔드에서 자체 보유한 토큰을 폐기하면 된다)
장비 관리 (with token)
장비 관리 프로세스를 토큰 검증을 넣어서 만들어보자.
먼저 model 파일부터 만들었다.
코드는 department.js를 참고했다.
const Sequelize = require('sequelize');
module.exports = class Device extends Sequelize.Model {
static init(sequelize) {
return super.init({
name: {
type: Sequelize.STRING(100),
unique: true,
allowNull: false,
},
deviceModelName: {
type: Sequelize.STRING(100),
},
manufacturer: {
type: Sequelize.STRING(100),
},
location: {
type: Sequelize.STRING(255),
},
edgeSerialNumber: {
type: Sequelize.STRING(20),
},
networkInterface: {
type: Sequelize.STRING(20),
},
networkConfig: {
type: Sequelize.TEXT,
},
description: {
type: Sequelize.TEXT,
},
}, {
sequelize,
underscored: true,
timestamps: true,
paranoid: true,
});
}
};
미들웨어 장착
일단 부서 리스트 조회에 장착해보기
미들웨어 불러오기
/routes/department.js
const { isLoggedIn } = require('../lib/middleware');
리스트 조회 항목에 isLoggedIn(미들웨어) 삽입
// 리스트 조회
router.get('/', isLoggedIn, async (req, res) => {
try {
// ...
미들웨어가 실행되어서 next()가 작동하면 async (req, res)~로 넘어간다.
미들웨어를 여러개 넣을 수도 있다.(isLoggedIn, middleware2, middleware3, ···)
포스트맨에서 막히는 걸 볼 수 있다.
발행한 토큰 헤더에 삽입하면 잘 뜬다.
일일히 치기 귀찮으니 환경 변수에 넣어서 쓸 수 있다.
토큰을 구분하기 위해
구분의 편리성을 위해 token-nodejs로 이름을 변경했다.
개발의 편의를 위해 1년짜리 토큰을 발행했다.
tokenUtil.js
// const options = {
// expiresIn: '2h', // 만료시간 : 2시간
// };
const options = {
expiresIn: '8760h', // 만료시간 : 1년
};
token-nodejs 토큰을 이걸로 덮어 씌웠다.
이제 미들웨어를 장착한 device의 CRUD를 만들어보자
첫번째 에러
먼저 등록부터 만들었는데 아래와 같은 에러가 떴다.
[error] (deviceService.reg) TypeError: Cannot read property 'create' of undefined
알고보니 deviceDao 파일에서 index 모델파일을 불러와야하는데 device 모델 파일을 불러오고 있었다.
const { Device } = require('../models/device');
// 아래와 같이 고쳐주기
const { Device } = require('../models/index');
index 모델 파일로 바꿔주니 잘 작동했다.
두번째 에러
장비 수정할 때 send하면 아래와 같이 값이 null로 떴다.(name이 not null이라 에러를 띄우는 모양)
역시 라우터 파일부터 확인해보니 내가 body에 넣어야하는걸 query에 넣어놨었다.
이런식으로...
// 수정
router.put('/:id', isLoggedIn, async (req, res) => {
// 미들웨어 isLoggedIn 삽입
try {
const params = {
id: req.params.id,
name: req.query.name,
deviceModelName: req.query.deviceModelName,
manufacturer: req.query.manufacturer,
location: req.query.location,
edgeSerialNumber: req.query.edgeSerialNumber,
networkInterface: req.query.networkInterface,
networkConfig: req.query.networkConfig,
description: req.query.description,
};
// 생략
그래서 다시 body에 제대로 넣어줬다.
// 수정
router.put('/:id', isLoggedIn, async (req, res) => {
// 미들웨어 isLoggedIn 삽입
try {
const params = {
id: req.params.id,
name: req.body.name,
deviceModelName: req.body.deviceModelName,
manufacturer: req.body.manufacturer,
location: req.body.location,
edgeSerialNumber: req.body.edgeSerialNumber,
networkInterface: req.body.networkInterface,
networkConfig: req.body.networkConfig,
description: req.body.description,
};
// 생략
세번째 에러
장비 삭제 만들 때 send하면 아래와 같이 파일을 못찾는다는 에러가 떴다.
일단 라우터 파일 문제인 것 같아서 라우터 파일을 살펴보았는데
router.delete가 아니라 router.post로 되어 있었다.
위에서부터 복붙 하다가 실수한 모양이다.
그래서 제대로 수정 했더니 잘 작동했다.
// 삭제
router.get('/:id', isLoggedIn, async (req, res) => {
// 아래와 같이 수정했다.
// 삭제
router.delete('/:id', isLoggedIn, async (req, res) => {
사용자 사전 등록 팁
사용자 관리에 isLoggedIn을 장착하면 최초의 사용자는 DB쿼리를 통해 아래처럼 입력할 수 밖에 없다(혹은 이를 우회할 별도의 accessKey 발급하거나)
insert into users (name, userid, password, role, created_at, updated_at)
values (
'시스템관리',
'system',
'r2TiJADp0RvKMlPitW/yVhsDhIwolUbxE0UsAPGOseOLkFD2T/fOF3LD14ZxU9WFxTSINjHsfyvW9K+aM07i9g==.7cdbac429c26f8d4074aa24d4b3481ee077c7edcf6bf0271cec0001b514ac79c569798e6877bd38c5e3581bcec1d07e11d17e9f93e8fa3672aa6edaa41419320',
'system',
now(),
now()
);
만드는 법은 isLoggedIn을 장착하기 전 먼저 사용자 등록을 하고나서 데이터를 그대로 복사해 쿼리에 이용하는 것이다(위 경우 비밀번호는 admin이다.).
코드 복습 팁
코드 짤 땐 dao -> service -> router 순으로 했지만
코드 복습할 땐 router -> service -> dao 순으로 들어가는 게 좋다
프론트엔드 연동
과거에 한 파일을 이용한다.
로그인 처리
/models/auth.js
action에 로그인 처리 부분의 테스트 데이터 대신 RestApi를 호출해볼 것이다.
actions: {
authLogin(context, payload) {
// 로그인 처리
// context에 id, pw가 들어옴
serverApi는 vue.config.js파일에서 target을 설정해뒀기 때문에 아래와 같이 작동한다.
/* RestApi 호출 */
api
.post('/serverApi/auths/token', payload)
// serverApi = http://localhost:3000/
.then(response => {
const token = response.headers.token
// 백엔드 auth에서 설정했던 headers에 set한 token을 불러온다
const decodedToken = jwtDecode(token)
// 정상인 경우 처리
context.commit('setLoading', false)
context.commit('setTokenUser', decodedToken)
})
.catch(error => {
// 에러인 경우 처리
context.commit('setLoading', false)
context.commit('setError', error)
})
},
이렇게 연동하면 DB에 있는 계정 정보에서 아이디나 pw를 다르게 입력하면 에러 메세지를 볼 수 있다.
정상 로그인 화면
백엔드의 로그 파일에서도 확인할 수 있다.
checkPassword 값에 따라 판단하는 것을 알 수 있다.
// 정상
2022-01-27 16:16:29.861[info] (auth.token.result) {"id":9,"userid":"id1","password":"FN+pu5LkmdGAbbIkiuWYcCcsx/qTdgjIEc9PQIX0Jfqg0vTfIcie4l59bhWctMKk1SxJKHqbMG6Yke5Q5QdEcA==.35b7897c3bcf746b2a6b70fcf069b8540b58a12199d7caa85f984574d990a889a08fe8684ff07b105b81064c9badeff61e7d102b05b6b66f46e2f60ed8d6b460","name":"bam7","role":"member"}
2022-01-27 17:14:44.963[info] (auth.token.params) {"userid":"id","password":"1"}
2022-01-27 17:14:45.057[debug] (userService.login) {"id":8,"userid":"id","password":"FN+pu5LkmdGAbbIkiuWYcCcsx/qTdgjIEc9PQIX0Jfqg0vTfIcie4l59bhWctMKk1SxJKHqbMG6Yke5Q5QdEcA==.35b7897c3bcf746b2a6b70fcf069b8540b58a12199d7caa85f984574d990a889a08fe8684ff07b105b81064c9badeff61e7d102b05b6b66f46e2f60ed8d6b460","name":"bam6","role":"member"}
2022-01-27 17:14:45.059[debug] (userService.checkPassword) true
// 비정상
2022-01-27 17:14:45.060[info] (auth.token.result) {"id":8,"userid":"id","password":"FN+pu5LkmdGAbbIkiuWYcCcsx/qTdgjIEc9PQIX0Jfqg0vTfIcie4l59bhWctMKk1SxJKHqbMG6Yke5Q5QdEcA==.35b7897c3bcf746b2a6b70fcf069b8540b58a12199d7caa85f984574d990a889a08fe8684ff07b105b81064c9badeff61e7d102b05b6b66f46e2f60ed8d6b460","name":"bam6","role":"member"}
2022-01-27 17:16:33.087[info] (auth.token.params) {"userid":"id","password":"asd"}
2022-01-27 17:16:33.147[debug] (userService.login) {"id":8,"userid":"id","password":"FN+pu5LkmdGAbbIkiuWYcCcsx/qTdgjIEc9PQIX0Jfqg0vTfIcie4l59bhWctMKk1SxJKHqbMG6Yke5Q5QdEcA==.35b7897c3bcf746b2a6b70fcf069b8540b58a12199d7caa85f984574d990a889a08fe8684ff07b105b81064c9badeff61e7d102b05b6b66f46e2f60ed8d6b460","name":"bam6","role":"member"}
2022-01-27 17:16:33.149[debug] (userService.checkPassword) false
2022-01-27 17:16:33.150[error] Error: incorrect userid or password
로그아웃 처리
프론트에서 알아서 처리함(토큰을 제거)
async authLogout(context) {
// 로그아웃 처리
// 상태값 초기화
context.commit('clearError')
context.commit('setLoading', true)
/* 테스트 데이터 세팅 */
setTimeout(() => {
context.commit('setLogout') // 로그아웃 처리
window.localStorage.removeItem('token') // 토큰 삭제
}, 1000) // 처리 시간을 1초로 주었다.
프론트 개발자는 토큰을 어디에 넘겨주는지, 어디 주소에 post 하는지 등등 api 문서로 확인할 수 있다.
부서관리 Api 수정
현재 부서 관리 페이지에 들어가면 다음과 같은 에러가 뜬다.
[Vue warn]: Invalid prop: type check failed for prop "items". Expected Array, Function, got Object
이는 프론트엔드에선 data를 통째로 가져오고 있으나
api
.get('/serverApi/departments')
.then(response => {
console.log('response', response)
const departmentList = response && response.data
context.commit('setDepartmentList', departmentList)
})
백엔드에서 데이터를 rows 안에 한번 더 담았기 때문이다.
따라서 다음과 같이 수정한다.
api
.get('/serverApi/departments')
.then(response => {
console.log('response', response)
const departmentList = response && response.data && response.data.rows
context.commit('setDepartmentList', departmentList)
})
부서관리 Store 연동
부서관리 store 파일에서 부서 등록 / 수정 / 삭제를 실제 백엔드와 연동시키기
부서 등록
신규 등록할 때 제대로 아래와 같이 입력했다.(data의 id에서 값을 받아옴)
const insertedResult = response && response.data && response.data.id
context.commit('setInsertedResult', insertedResult)
그러나 콘솔에 500 에러가 뜨면서 데이터를 불러오질 못했다.(분명 백엔드엔 잘 들어갔다.)
등록 버튼을 누르면 등록이 실패하였습니다 toast가 뜨면서 새로고침해줘야 데이터가 불러와졌다.
그 이유는 내가 백엔드의 라우터 파일에서 최종 응답할때 결과값을 담지 않고 문구를 넣어버렸기 때문이었다.
// 부서 등록
// '/' = '/departments' (라우터 등록)
router.post('/', isLoggedIn, async (req, res) => {
// ...
// 최종 응답
res.status(200).json({ result: 'success ' }); // 이 부분!
} catch (err) {
res.status(500).json({ err: err.toString() });
}
저렇게 처리하니 콘솔에 찍었을 때 data가 result: 'success'가 뜨면서 아무 정보도 담지 못했다.
따라서 아래처럼 result로 수정했더니 작동이 잘 됐다.
// 최종 응답
res.status(200).json(result);
} catch (err) {
res.status(500).json({ err: err.toString() });
}
부서 수정
response.data.updatedCount로 수정했다.
const updatedResult = response && response.data && response.data.updatedCount
context.commit('setUpdatedResult', updatedResult)
부서 삭제
response.data.deletedCount로 수정했다.
const deletedResult = response && response.data && response.data.deletedCount
context.commit('setDeletedResult', deletedResult)
수정에서 updatedCount / 삭제에서 deletedCount를 넣는 이유
프론트에서 updatedCount > 0이면 백엔드에서 수정이 된 것이다. 하고 리스트를 재검색한다. 그러면 DB에서 수정된 데이터가 불러와진다.
느낀 점
예전에 지나쳤던 RestAPI가 이렇게 쓰이는 거구나...하고 깨달았다.
혼자 먼저 해볼때 updatedCount를 넣는 건 상상도 못했다. 백엔드랑 DB랑 연동하는 것에 아직 완전히 익숙하진 않은 것 같다.
이제 익숙해질 일만 남았다!
'공부 > Boot camp' 카테고리의 다른 글
TIL_220204_HMI (0) | 2022.02.04 |
---|---|
TIL_220128_토큰 관리 / 검색 / 로그인 (0) | 2022.01.28 |
TIL_220126_Backend_CRUD (0) | 2022.01.26 |
TIL_220125_Backend_CRUD (0) | 2022.01.25 |
TIL_220124_Backend (0) | 2022.01.25 |