Vue.js 3 & NodeJS/Vue 3

Vue CLI 레스트 API 인증 정보 복호화 (Access-Token) - Vue CLI REST API Authentication, Base64 btoa/atob

carrotweb 2021. 11. 6. 21:56
728x90
반응형

Login을 하면 인증처리를 위해 레스트 API에서 JWT(JSON Web Token)를 이용한 Access-Token를 생성하여 처리하였습니다. 그러나 네트워크 해킹을 통해 Access-Token를 탈취하거나 브라우저의 쿠키(cookie)를 통해 쉽게 Access-Token를 탈취할 수 있어 보안에 취약합니다.

브라우저를 통한 Access-Token 탈취

브라우저에서 개발도구(F12)를 이용하여 Network로 Response(응답) 되는 정보를 확인할 수 있습니다.

login의 Response 내용을 통해 accessToken을 확인할 수 있습니다.

{"success":true,"accessToken":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJtZW1iZXJJZCI6InRlc3RpZDEiLCJtZW1iZXJOYW1lIjoi7ZmN6ri464-ZIiwiaWF0IjoxNjM0NDcxMDEwLCJleHAiOjE2MzQ1NTc0MTB9.OgLNeQPVm6j86sqQp4sMs_VVnaiQjHAn0G94PzorNNM"}

 

또는 로그인 후 게시물 수정 컴포넌트로 이동하여 Network로 Request(요청) 되는 정보를 확인할 수 있습니다.

/board/boardedit?boardNo=1의 Request Headers 내용을 통해 accessToken이 헤더에서 어떻게 사용되는지 확인할 수 있습니다.

Host: localhost:9000
Connection: keep-alive
sec-ch-ua: "Chromium";v="94", "Google Chrome";v="94", ";Not A Brand";v="99"
Accept: application/json, text/plain, */*
Access-Token: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJtZW1iZXJJZCI6InRlc3RpZDEiLCJtZW1iZXJOYW1lIjoi7ZmN6ri464-ZIiwiaWF0IjoxNjM0NDcxMDEwLCJleHAiOjE2MzQ1NTc0MTB9.OgLNeQPVm6j86sqQp4sMs_VVnaiQjHAn0G94PzorNNM
sec-ch-ua-mobile: ?0
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/94.0.4606.81 Safari/537.36
sec-ch-ua-platform: "Windows"
Origin: http://localhost:8080
Sec-Fetch-Site: same-site
Sec-Fetch-Mode: cors
Sec-Fetch-Dest: empty
Referer: http://localhost:8080/
Accept-Encoding: gzip, deflate, br
Accept-Language: ko,en;q=0.9,en-US;q=0.8,ko-KR;q=0.7
If-None-Match: W/"91-VYx48P0wD0xxks4wtEzkcVMcPpA"

 

위의 Access-Token를 복사하여 jwt.io 웹 사이트의 Encoded에 붙여 넣기 하면 Decoded에서 확인할 수 있습니다.

 

이처럼 쉽게 Access-Token를 탈취할 수 있어 사용할 수 있기 때문에 토큰의 만료 시간을 짧게 하여 탈취되더라도 사용할 수 없게 하는 것이 좋습니다.

현재 토큰의 만료 시간은 1일로 되어 있습니다.

iat(Issued At Time, 발급된 시간) : 1634471010 --> 2021/10/17 20:43:30
exp(Expiration Time, 만료 시간) : 1634557410 --> 2021/10/18 20:43:30

그래서 토큰의 만료 시간을 1시간 이내로 설정하는 것이 좋습니다. 만약 개인 정보를 많이 취급하는 부분이 있다면 보안을 위해 만료 시간을 10분 이내로 설정하는 것이 좋습니다. 대부분의 은행들이 로그인 유지를 10분으로 제안하고 있습니다.

만료 시간이 짧을수록 보안은 높아지지만 Access-Token을 재발급하기 위해 다시 로그인해야 하는 문제가 있습니다. 그래서 다시 로그인하지 않고 Access-Token을 재발급하기 위해서 또 다른 토큰인 Refresh-Token이 필요합니다.

Refresh-Token은 Access-Token이 만료되면 재발급하는 용도로 사용되기 때문에 만료 시간이 길게 설정됩니다.

로그인이 유지되지 않는다면 Refresh-Token의 만료 시간은 1일 이내로 설정하고 로그인이 유지되어야 한다면 길게 설정합니다.

 

Access-Token 만료 확인

프로그램적으로 Access-Token의 만료 확인은 레스트 API를 호출할 때 알 수 있습니다.

다음은 레스트 API를 요청했을 때의 응답 결과로 상태는 403 (Forbidden)이고 에러 메시지는 인증 실패(Authentication fail)입니다.

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

브라우저의 Console는 다음과 같습니다.

Failed to load resource: the server responded with a status of 403 (Forbidden)
Error: Request failed with status code 403
    at createError (createError.js?2d83:16)
    at settle (settle.js?467f:17)
    at XMLHttpRequest.handleLoad (xhr.js?b50d:62)

 

그래서 Access-Token의 만료를 확인한 후 Access-Token를 재발급하거나 레스트 API를 호출하기 전에 Access-Token이 만료되었는지 확인하여 Access-Token를 재발급하면 됩니다.

그럼 Vue에서 어떻게 Access-Token이 만료되었는지 어떻게 확인할 수 있을까요?

이전에 "Node.js 레스트 API 인증처리(JWT 생성 및 검증)"에서 설명드린 것처럼 JWT의 header, payload는 JSON을 base64UrlEncode() 메서드로 인코딩한 문자열입니다.

그래서 Javascript에는 Base64를 암호화(btoa()) / 복호화(atob()) 메서드를 이용하면 문자열을 암/복호화할 수 있습니다.

 

Base64 btoa() / atob() 테스트

영문 문자열을 btoa() 메서드로 암호화하고 atob() 메서드로 복호화해보겠습니다.

var text = btoa("abcd");
console.log("btoa : " + text);
text = atob(text);
console.log("atob : " + text);
btoa : YWJjZA==
atob : abcd

정상적으로 처리됩니다.

그러나 한글이 포함되는 문자열을 암호화하면 에러가 발생합니다.

var text = btoa("ab 홍길동 cd");
console.log("btoa : " + text);
text = atob(text);
console.log("atob : " + text);
Uncaught (in promise) DOMException: Failed to execute 'btoa' on 'Window': 
The string to be encoded contains characters outside of the Latin1 range.

그 이유는 인코딩할 문자열에 Latin1 범위를 벗어난 문자가 포함되어 있기 때문입니다.

그래서 한글이 포함되는 문자열을 암호화하기 전에 인코딩을 해야 합니다.

Javascript에서 인코딩에 사용되는 메서드는 escape()와 encodeURIComponent()가 있습니다.

escape() / unescape() 테스트

한글이 포함되는 문자열을 암호화하기 전에 escape() 메서드로 처리하고 복호화한 후 unescape() 메서드로 처리하면 됩니다.

var text = escape("ab 홍길동 cd");
console.log("escape : " + text);
text = btoa(text);
console.log("btoa : " + text);
text = atob(text);
console.log("atob : " + text);
text = unescape(text);
console.log("unescape : " + text);
escape : ab%20%uD64D%uAE38%uB3D9%20cd
btoa : YWIlMjAldUQ2NEQldUFFMzgldUIzRDklMjBjZA==
atob : ab%20%uD64D%uAE38%uB3D9%20cd
unescape : ab 홍길동 cd

escape()는 문자열을 ASCII 코드로 변환합니다. 그리고 ASCII 코드 범위를 넘는 1Byte 문자는 %XX로 2Byte 문자는 %uXXXX으로 변환합니다.

홍 -> %uD64D

 

반응형

 

encodeURIComponent() / decodeURIComponent() 테스트

한글이 포함되는 문자열을 암호화하기 전에 encodeURIComponent() 메서드로 처리하고 복호화한 후 decodeURIComponent() 메서드로 처리하면 됩니다

var text = encodeURIComponent("ab 홍길동 cd");
console.log("encodeURIComponent : " + text);
text = btoa(text);
console.log("btoa : " + text);
text = atob(text);
console.log("atob : " + text);
text = decodeURIComponent(text);
console.log("decodeURIComponent : " + text);
encodeURIComponent : ab%20%ED%99%8D%EA%B8%B8%EB%8F%99%20cd
btoa : YWIlMjAlRUQlOTklOEQlRUElQjglQjglRUIlOEYlOTklMjBjZA==
atob : ab%20%ED%99%8D%EA%B8%B8%EB%8F%99%20cd
decodeURIComponent : ab 홍길동 cd

encodeURIComponent()는 URI로 데이터를 전달하기 위해서 모든 문자열을 UTF-8로 인코딩해서 인코딩 ASCII 코드로 변환합니다. Byte 단위로 %XX로 변환합니다.

홍 -> %ED%99%8D

 

 

Access-Token 복호화

Login Store 모듈에서 Access-Token를 .(도트)로 분리하여 payload만 가져옵니다.

var base64Payload = this.$store.state.loginStore.accessToken.split('.')[1];
eyJtZW1iZXJJZCI6InRlc3RpZDEiLCJtZW1iZXJOYW1lIjoi7ZmN6ri464-ZIiwiaWF0IjoxNjM0NDcxMDEwLCJleHAiOjE2MzQ1NTc0MTB9

 

payload를 atob() 메서드로 복호화합니다.

base64Payload = atob(base64Payload);
console.log(base64Payload);

그러나 디코딩할 문자열이 올바르게 인코딩 되지 않았다는 에러가 발생합니다.

Uncaught (in promise) DOMException: Failed to execute 'atob' on 'Window':
The string to be decoded is not correctly encoded.

그 이유는 URL과 호환되지 않는 문자들('-', '_')이 있기 때문입니다.

그래서 URL과 호환되지 않는 '-'은 '+'으로 '_'은 '/'으로 변환합니다.

base64Payload = base64Payload.replace(/-/g, '+').replace(/_/g, '/');
base64Payload = atob(base64Payload);
console.log("atob : " + base64Payload);

정상적으로 복호화되어서 JSON 문자열이 나옵니다.

atob : {"memberId":"testid1","memberName":"홍길동","iat":1634471010,"exp":1634557410}

그러나 한글 이름으로 된 memberName의 값은 깨져서 나옵니다.

그 이유는 한글이 인코딩 되어 있기 때문입니다.

우선 JSON 문자열을 JSON 객체로 변환합니다.

var payloadObject = JSON.parse(base64Payload);
console.log(payloadObject);
{memberId: 'testid1', memberName: 'í\x99\x8D길ë\x8F\x99', iat: 1634471010, exp: 1634557410}
{
    "memberId": "testid1",
    "memberName": "홍길동",
    "iat": 1634471010,
    "exp": 1634557410
}

 

memberName의 값을 디코딩해보겠습니다.

console.log("decodeURIComponent : " + decodeURIComponent(payloadObject.memberName));

그렇지만 변환되는 게 없습니다.

decodeURIComponent : 홍길동

그 이유는 memberName의 값이 문자가 아닌 Byte로 되어 있기 때문입니다.

memberName의 값을 escape()로 Byte 문자로 변환해보겠습니다.

console.log("escape : " + escape(payloadObject.memberName));

그 결과 encodeURIComponent() 메서드로 처리된 Byte 단위의 %XX로 변환되어서 나옵니다.

escape : %ED%99%8D%EA%B8%B8%EB%8F%99

 

decodeURIComponent() 메서드로 디코딩하면 정상적으로 한글을 가져오게 됩니다.

var memberName = decodeURIComponent(escape(payloadObject.memberName));
console.log("memberName : " + memberName);
memberName : 홍길동

 

 

 

Access-Token의 만료 시간 확인

만료 시간을 확인하기 위해서 exp의 값을 Date 객체로 변환합니다. Date 객체는 밀리세컨드(millisecond)로 되어 있기 때문에 변환하기 위해 exp의 값에 1000을 곱해야 합니다.

var expDate = new Date(payloadObject.exp * 1000);
console.log("exp : " + expDate);
console.log("exp : " + expDate.getFullYear() + "/" + (expDate.getMonth() + 1) + "/" + expDate.getDate() + " " + expDate.getHours() + ":" + expDate.getMinutes() + ":" + expDate.getSeconds());
exp : Mon Oct 18 2021 20:43:30 GMT+0900 (한국 표준시)
exp : 2021/10/18 20:43:30

 

발급된 시간을 확인하기 위해서 iat의 값을 Date 객체로 변환합니다.

var iatDate = new Date(payloadObject.iat * 1000);
console.log("iat : " + iatDate);
console.log("iat : " + iatDate.getFullYear() + "/" + (iatDate.getMonth() + 1) + "/" + iatDate.getDate() + " " + iatDate.getHours() + ":" + iatDate.getMinutes() + ":" + iatDate.getSeconds());
iat : Sun Oct 17 2021 20:43:30 GMT+0900 (한국 표준시)
iat : 2021/10/17 20:43:30

 

Access-Token이 유효한지 만료되었는지 확인하기 위해 현재 시간과 비교하면 됩니다.

var currentDate = new Date().getTime() / 1000;

if (payloadObject.exp < current_time) {
	console.log("token expired");
} else {
	console.log("token valid");
}
token expired

 

이제 Access-Token을 복호화하여 payload 정보를 활용할 수 있게 되었습니다. 그리고 만료 시간을 확인할 수 있어 Access-Token을 레스트 API를 호출하기 전에 재발급할 수 있게 되었습니다.

 

전체 소스입니다.

var text = btoa("abcd");
console.log("btoa : " + text);
text = atob(text);
console.log("atob : " + text);

var text = escape("ab 홍길동 cd");
console.log("escape : " + text);
text = btoa(text);
console.log("btoa : " + text);
text = atob(text);
console.log("atob : " + text);
text = unescape(text);
console.log("unescape : " + text);

var text = encodeURIComponent("ab 홍길동 cd");
console.log("encodeURIComponent : " + text);
text = btoa(text);
console.log("btoa : " + text);
text = atob(text);
console.log("atob : " + text);
text = decodeURIComponent(text);
console.log("decodeURIComponent : " + text)

// Access-Token 복호화
//var base64Payload = this.$store.state.loginStore.accessToken.split('.')[1];
var base64Payload = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJtZW1iZXJJZCI6InRlc3RpZDEiLCJtZW1iZXJOYW1lIjoi7ZmN6ri464-ZIiwiaWF0IjoxNjM0NDcxMDEwLCJleHAiOjE2MzQ1NTc0MTB9.OgLNeQPVm6j86sqQp4sMs_VVnaiQjHAn0G94PzorNNM";
base64Payload = base64Payload.split('.')[1];
console.log(base64Payload);

// URL과 호환되지 않는 문자를 base64 표준 문자로 교체
base64Payload = base64Payload.replace(/-/g, '+').replace(/_/g, '/');

// Base64는 4로 나누어진다. 그래서 길이를 4로 나눈 나머지 길이 만큼 "="가 추가되어야 한다.
//var pad = base64Payload.length % 4;
//if (pad > 0) {
//	base64Payload += new Array(5 - pad).join('=');
//	console.log(base64Payload);
//}

base64Payload = atob(base64Payload);
console.log("atob : " + base64Payload);

var payloadObject = JSON.parse(base64Payload);
console.log(payloadObject);

console.log("decodeURIComponent : " + decodeURIComponent(payloadObject.memberName));

// ASCII(아스키) 문자를 유니코드 형식으로 변환
console.log("escape : " + escape(payloadObject.memberName));

// 문자열에서 encoding 된 URI를 decoding 한다.
var memberName = decodeURIComponent(escape(payloadObject.memberName));
console.log("memberName : " + memberName);

// Access-Token의 만료 시간 확인
var expDate = new Date(payloadObject.exp * 1000);
console.log("exp : " + expDate);
console.log("exp : " + expDate.getFullYear() + "/" + (expDate.getMonth() + 1) + "/" + expDate.getDate() + " " + expDate.getHours() + ":" + expDate.getMinutes() + ":" + expDate.getSeconds());

var iatDate = new Date(payloadObject.iat * 1000);
console.log("iat : " + iatDate);
console.log("iat : " + iatDate.getFullYear() + "/" + (iatDate.getMonth() + 1) + "/" + iatDate.getDate() + " " + iatDate.getHours() + ":" + iatDate.getMinutes() + ":" + iatDate.getSeconds());

var currentDate = new Date().getTime() / 1000;
console.log("Current Date : " + currentDate);

if (payloadObject.exp < currentDate) {
	console.log("token expired");
} else {
	console.log("token valid");
}
728x90
반응형