Vue.js 3 & NodeJS/NodeJS

Node.js 레스트 API 인증처리(JWT 생성 및 검증) - Node.js REST API Authentication JWT(JSON Web Token) Sign, Verify

carrotweb 2021. 8. 16. 22:59
728x90
반응형

JWT(JSON Web Token - JSON 웹 토큰)은 두 개체 사이에서 안전하게 클레임을 전달(표현)해주는 산업 표준 RFC 7519 방법입니다. (JSON Web Tokens are an open, industry standard RFC 7519 method for representing claims securely between two parties.)

JWT는 header.payload.signature로 구성되어 있습니다. 도트(.)로 구분되어 있습니다.

header는 ALGORITHM & TOKEN TYPE으로 JWT 생성할 때 사용된 정보들로 검증할 때 사용됩니다. JSON을 base64UrlEncode() 메서드로 인코딩한 문자열입니다.

payload는 DATA로 JWT에 저장되는 정보로 key : value로 구성됩니다. JSON을 base64UrlEncode() 메서드로 인코딩한 문자열입니다.

payload는 암호화되지 않기 때문에 개인정보는 사용하지 않아야 합니다.

AES 암호를 사용하여 payload를 암호화할 수 있습니다.

signature는 JWT에 대한 검증(확인)할 때 사용되는 암호화된 문자열입니다.

jsonwebtoken 모듈 설치하기

JWT(JSON Web Token)을 생성(sign - 서명)하고 검증(Verify - 확인)해주는 jsonwebtoken 모듈을 설치하기 위해 콘솔에서 npm install 명령어를 실행합니다.

npm install --save jsonwebtoken

package.json 파일의 dependencies에 추가됩니다.

"dependencies": {
  "dateformat": "^4.5.1",
  "express": "^4.17.1",
  "jsonwebtoken": "^8.5.1" --> 추가
}

 

 

회원 로그인(JWT Sign - 생성)을 위한 REST API 만들기

1. C:\workspaces\nodeserver\testrestapi 폴더에서 회원 로그인과 인증 처리를 위한 REST API를 만들기 위해 memberapi.js 파일을 생성합니다.

 

memberapi.js 파일을 오픈하여 이전에 설치한 express.js를 이용하기 위해 가져오고 라우터를 가져옵니다.

const express  = require('express');
const router = express.Router();

 

JWT(JSON Web Token - JSON 웹 토큰)을 생성하기 위해 jsonwebtoken 모듈을 가져옵니다.

const jwt = require('jsonwebtoken');

 

DB를 사용하지 않기 때문에 데이터로 사용자 객체를 가지고 있는 배열을 생성하고 초기 데이터를 등록합니다.

let memberList = [
{id:"testid1", password:"testpwd1", name:"홍길동"},
{id:"testid2", password:"testpwd2", name:"김철수"},
{id:"testid3", password:"testpwd3", name:"이영희"}];

사용자 객체는 아이디(id), 패스워드(password), 이름(name)으로 구성됩니다.

 

POST Method - Login &JWT Sign

HTTP POST 메서드로 요청(Request)이 들어오면 사용자 배열에서 아이디(id)로 검색하고 패스워드(password)를 검증하여 JWT(JSON Web Token - JSON 웹 토큰)를 생성(서명)하여 리턴합니다.

router.post('/login', function(req, res, next) {
	console.log("REST API Post Method - Member Login And JWT Sign");
	const memberId = req.body.id;
	const memberPassword = req.body.password;
	var memberItem = memberList.find(object => object.id == memberId);
	if (memberItem != null) {
		if (memberItem.password == memberPassword) {
			const secret = "005c9780fe7c11eb89b4e39719de58a5";
			jwt.sign({
					memberId : memberItem.id,
					memberName : memberItem.name
				},
				secret,
				{
					expiresIn : '1d'
				},
				(err, token) => {
					if (err) {
						console.log(err);
						res.status(401).json({success:false, errormessage:'token sign fail'});
					} else {
						res.json({success:true, accessToken:token});
					}
				});
		} else {
			res.status(401).json({success:false, errormessage:'id and password are not identical'});
		}
	} else {
		res.status(401).json({success:false, errormessage:'id and password are not identical'});
	}
});

로그인과 인증이 실패할 때는 상태 코드를 401로 하였습니다.

req.body에서 값을 가져올 때 개별 또는 여러 개를 가져올 수 있습니다. 단 여러 개를 가져올 때는 req.body안에 있는 변수명과 동일해야 합니다.

const memberId = req.body.id;
const memberPassword = req.body.password;

또는

const { id, password } = req.body; (O)
const { memberId, memberPassword } = req.body; (X)

 

jwt.sign(payload, secret, options, [callback])

payload에는 JWT에 저장되는 정보로 key : value로 구성됩니다.

여기서는 로그인 사용자를 구분하기 위해 아이디(memberId)와 이름(memberName)을 저장합니다.

secret는 서명을 만들 때 사용되는 암호 문자열입니다.

여기서는 UUID를 암호 문자열로 사용했습니다.

options은 JWT를 생성할 때 사용되는 옵션입니다.

algorithm은 암호화 알고리즘으로 HS256, HS384, HS512, RS256, RS384, RS512, ES256, ES384, ES512, PS256, PS384 등을 사용할 수 있습니다. 기본값으로 HS256를 사용합니다.

expiresIn(exp)는 JWT 만료(유효) 시간으로 초 단위 또는 시간 범위 문자열로 설정합니다.

년 단위 : "1y", 일 단위 : "1 days", "1d", 시간 단위 : "2.5 hrs", "2h", 분 단위 : "1m", 초 단위 : "5s"

단위 문자가 포함되지 않으면 초 단위로 설정됩니다.

notbefore(nbf)는 JWT를 처리하지 않는 시간으로 초 단위 또는 시간 범위 문자열로 설정합니다.

expiresIn와 동일한 표기법을 사용합니다.

audience(aud)는 JWT를 사용할 수신자입니다.

issuer(iss)는 JWT 발급자입니다. 도메인을 많이 사용합니다.

subject(sub)는 claim의 주제입니다. JWT의 사용 목적이나 JWT의 정보 객체 단위를 사용합니다.

jwtid는 JWT 식별자입니다.

추가로 noTimestamp, header, keyid, mutatePayload 등이 있습니다.

callback은 JWT가 생성된 후 호출되는 funcation입니다. 생성된 토큰(token)이나 에러(err)를 인자로 줍니다.

 

JavaScript의 비동기 처리로 인해 callback이 호출되기 전에 처리가 완료될 수 있습니다. 그래서 jwt.sign() 메서드를 Promise로 처리하고 async와 await로 동기 처리되게 해야 합니다.

router.post('/login', async function(req, res, next) {
	console.log("REST API Post Method - Member Login And JWT Sign");
	const memberId = req.body.id;
	const memberPassword = req.body.password;
	var memberItem = memberList.find(object => object.id == memberId);
	if (memberItem != null) {
		if (memberItem.password == memberPassword) {
			const secret = "005c9780fe7c11eb89b4e39719de58a5";
			try {
				const accessToken = await new Promise((resolve, reject) => {
					jwt.sign({
							memberId : memberItem.id,
							memberName : memberItem.name
						},
						secret,
						{
							expiresIn : '1d'
						},
						(err, token) => {
							if (err) {
								reject(err);
							} else {
								resolve(token);
							}
						});
				});
				res.json({success:true, accessToken:accessToken});
			} catch(err) {
				console.log(err);
				res.status(401).json({success:false, errormessage:'token sign fail'});
			}
		} else {
			res.status(401).json({success:false, errormessage:'id and password are not identical'});
		}
	} else {
		res.status(401).json({success:false, errormessage:'id and password are not identical'});
	}
});

 

생선 된 라우터를 export 합니다.

module.exports = router;

 

2. C:\workspaces\nodeserver\testrestapi 폴더에서 환경 설정 정보를 관리하기 위해 config.js 파일을 생성합니다.

 

config.js 파일에 JWT Sign() 메서드와 Verify() 메서드에서 secret를 같이 사용하기 위해 설정합니다.

module.exports = {
    'secret' : '005c9780fe7c11eb89b4e39719de58a5'
};

 

memberapi.js 파일을 오픈하여 config.js를 가져옵니다.

const config = require('./config.js');

 

그리고 const secret를 삭제하고 secret를 config.secret로 변경합니다.

jwt.sign({
		memberId : memberItem.id,
		memberName : memberItem.name
	},
	config.secret,
	{
		expiresIn : '1d'
	},
	(err, token) => {
		if (err) {
			reject(err);
		} else {
			resolve(token);
		}
	});

 

3. index.js 파일을 오픈하여 require() 메서드로 memberapi를 불러와서 웹 경로("/members")로 사용되게 설정합니다.

app.use('/members', require('./memberapi'));

 

4. npm run 명령어로 실행할 수 있습니다.

npm run start

 

반응형

 

5. Postman(포스트맨)를 실행하여 /members/login를 테스트합니다.

HTTP POST Method

http://localhost:9000/members/login

Request Body

{
    "id" : "testid1",
    "password" : "testpwd1"
}

아이디와 패스워드로 로그인 처리되어 리턴 값에 success가 true이고 accesToken에는 생성된 JWT를 가져옵니다.

{
    "success": true,
    "accessToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJtZW1iZXJJZCI6InRlc3RpZDEiLCJtZW1iZXJOYW1lIjoi7ZmN6ri464-ZIiwiaWF0IjoxNjI5NDQ5MTg2LCJleHAiOjE2Mjk1MzU1ODZ9.jXusEHE8dFDwp0xC81jPIFuBFMyqfDBZKnA0byXJvac"
}

 

jwt.io에서 JWT를 Encoded, Decoded를 할 수 있습니다.

 

JWT 검증(Verify - 확인)을 위한 미들웨어(Middleware) 만들기

라우터에서 요청을 처리하기 전에 미들웨어를 통해 JWT를 검증(확인)하여 검증(확인)된 요청만 처리하도록 합니다.

1. C:\workspaces\nodeserver\testrestapi 폴더에서 JWT를 검증(확인) 하기 위한 미들웨어를 만들기 위해 authmiddleware.js 파일을 생성합니다.

 

authmiddleware.js 파일을 오픈하여 JWT(JSON Web Token - JSON 웹 토큰)을 확인하기 위해 jsonwebtoken 모듈을 가져오고 config.js를 가져옵니다.

const jwt = require('jsonwebtoken');
const config = require('./config.js');

 

요청 헤더를 확인하여 accesstoken으로 JWT가 전달되었는지 확인하고 JWT를 검증(확인)하여 유효하면 tokenInfo에 데이터를 추가하고 다음 체인으로 처리되게 합니다. 그러나 유효하지 않으면 상태 코드를 403으로 처리를 중지시킵니다.

const authMiddleware = async (req, res, next) => {
	const accessToken = req.header('Access-Token');
	if (accessToken == null) {
		res.status(403).json({success:false, errormessage:'Authentication fail'});
	} else {
		try {
			const tokenInfo = await new Promise((resolve, reject) => {
				jwt.verify(accessToken, config.secret, 
					(err, decoded) => {
						if (err) {
							reject(err);
						} else {
							resolve(decoded);
						}
					});
			});
			req.tokenInfo = tokenInfo;
			next();
		} catch(err) {
			console.log(err);
			res.status(403).json({success:false, errormessage:'Authentication fail'});
		}
	}
}

 

 

jwt.verify(token, secretOrPublicKey, [options, callback])

token은 jwt.sign() 메서드로 생성된 JWT입니다.

secretOrPublicKey은 서명을 만들 때 사용된 암호 문자열입니다.

callback은 JWT가 검증(확인)된 후 호출되는 funcation입니다. payload 값(decode)이나 에러(err)를 인자로 줍니다.

생선 된 미들웨어를 export 합니다.

module.exports = authMiddleware;

 

2. index.js 파일을 오픈하여 require() 메서드로 authmiddleware를 불러와서 웹 경로("/boards")로 이전에 있던 웹 경로("/boards") 위에 추가 설정합니다. 그러면 웹 경로("/boards") 전체에 JWT 검증을 한 후 처리되게 됩니다.

app.use('/boards', require('./authmiddleware')); --> 추가
app.use('/boards', require('./boardapi'));

 

또는 라우트 별로 authmiddleware를 추가하려면 다음과 같이 하면 됩니다.

boardapi.js 파일을 오픈한 후 require() 메서드로 authmiddleware를 가져옵니다.

const authMiddleware = require('./authmiddleware');

그리고 authmiddleware를 추가하고 싶은 라우트에 추가합니다.

router.post('/', authMiddleware, function(req, res, next) {

 

여기서는 게시판 등록, 수정, 삭제일 때만 JWT 검증을 한 후 처리되게 하겠습니다.

router.post('/', authMiddleware, function(req, res, next) {
router.put('/:no', authMiddleware, function(req, res, next) {
router.delete('/:no', authMiddleware, function(req, res, next) {

 

3. npm run 명령어로 실행할 수 있습니다.

npm run start

 

4. Postman(포스트맨)를 실행하여 게시판 리스트를 가져오기 위해 GET Method로 호출합니다.

http://localhost:9000/boards

정상적으로 게시판 리스트를 가져옵니다.

 

게시판에 새로운 게시물을 등록하기 위해 POST Method로 호출합니다.

 

http://localhost:9000/boards

Request Body

{
    "subject" : "테스트 제목4",
    "content" : "테스트 내용4",
    "writer" : "testid1"
}

요청 헤더에 accessToken이 없어 인증되지 않았습니다.

{
    "success": false,
    "errormessage": "Authentication fail"
}

 

로그인 처리를 위해 POST Method로 호출합니다.

http://localhost:9000/members/login

Request Body

{
    "id" : "testid1",
    "password" : "testpwd1"
}

{
    "success": true,
    "accessToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJtZW1iZXJJZCI6InRlc3RpZDEiLCJtZW1iZXJOYW1lIjoi7ZmN6ri464-ZIiwiaWF0IjoxNjI5NDUwMzU0LCJleHAiOjE2Mjk1MzY3NTR9.BkgqQOzoGBEbTF3cwBB08cUxCmUZVkMYRi1Z6XWAEf4"
}

 

헤더 KEY에 "Access-Token"를 추가하고 VALUE에 위에서 생성된 JWT를 추가하고 다시 게시판에 새로운 게시물을 등록하기 위해 POST Method로 호출합니다.

정상적으로 새로운 게시물이 게시판에 등록됩니다.

 

authmiddleware.js에서 JWT를 검증(확인)하여 유효하면 req.tokenInfo에 JWT의 payload를 디코딩하여 추가하였습니다.

boardapi.js 파일에 게시물 등록에서 게시물 작성자(writer)를 인증된 로그인 사용자의 아이디(req.tokenInfo.memberId)로 처리되게 수정합니다.

router.post('/', authMiddleware, function(req, res, next) {
	console.log("REST API Post Method - Create");
	var boardLastItem = boardList.reduce((previous, current) => previous.no > current.no ? previous:current);
	var boardItem = new Object();
	boardItem.no = boardLastItem.no + 1;
	boardItem.subject = req.body.subject;
	boardItem.content = req.body.content;
	boardItem.writer = req.tokenInfo.memberId;
	boardItem.writedate = dateFormat(new Date(), "yyyy-mm-dd HH:MM:ss");
	boardList.push(boardItem);
	res.json({success:true});
});

 

그리고 게시물 수정이나 삭제일 때는 검증뿐만 아니라 인증된 로그인 사용자와 작성자를 비교하여 같은 아이디일 때만 수정이나 삭제하게 처리합니다.

boardapi.js 파일에 게시물 수정에서 등록된 게시물 작성자(writer)와 인증된 로그인 사용자의 아이디(req.tokenInfo.memberId)를 비교하여 처리되게 수정합니다. 동일하지 않으면 상태 코드를 403으로 하고 처리를 중지시킵니다.

router.put('/:no', authMiddleware, function(req, res, next) {
	console.log("REST API Put Method - Update " + req.params.no);
	var boardItem = boardList.find(object => object.no == req.params.no);
	if (boardItem != null) {
		if (boardItem.writer == req.tokenInfo.memberId) {
			boardItem.subject = req.body.subject;
			boardItem.content = req.body.content;
			boardItem.writedate = dateFormat(new Date(), "yyyy-mm-dd HH:MM:ss");
			res.json({success:true});
		} else {
			res.status(403);
			res.json({success:false, errormessage:'id are not identical'});
		}
	} else {
		res.status(404);
		res.json({success:false, errormessage:'not found'});
	}
});

 

등록된 게시물 작성자(writer)와 인증된 로그인 사용자의 아이디(req.tokenInfo.memberId)를 같지 않으면 처리되지 않고 "id are not identical"라고 리턴됩니다.

{
    "success": false,
    "errormessage": "id are not identical"
}

 

동일하게 boardapi.js 파일에 게시물 삭제에서 등록된 게시물 작성자(writer)와 인증된 로그인 사용자의 아이디(req.tokenInfo.memberId)를 비교하여 처리되게 수정합니다. 동일하지 않으면 상태 코드를 403으로 하고 처리를 중지시킵니다.

router.delete('/:no', authMiddleware, function(req, res, next) {
	console.log("REST API Delete Method - Delete " + req.params.no);
	var boardItem = boardList.find(object => object.no == req.params.no);
	if (boardItem != null) {
		if (boardItem.writer == req.tokenInfo.memberId) {
			var index = boardList.indexOf(boardItem);
			if (index >= 0) {
				boardList.splice(index, 1);
				res.json({success:true});
			} else {
				res.status(404);
				res.json({success:false, errormessage:'not found'});
			}
		} else {
			res.status(403);
			res.json({success:false, errormessage:'id are not identical'});
		}
	} else {
		res.status(404);
		res.json({success:false, errormessage:'not found'});
	}
});

 

등록된 게시물 작성자(writer)와 인증된 로그인 사용자의 아이디(req.tokenInfo.memberId)를 같지 않으면 처리되지 않고 "id are not identical"라고 리턴됩니다.

{
    "success": false,
    "errormessage": "id are not identical"
}

 

728x90
반응형