나이스 본인인증을 도입하던 중 나이스 측으로부터 Java로만 작성된 예시 코드를 제공받을 수 있어고, 해당 코드를 Node.js로 마이그레이션 하는 과정에서 꽤 많은 공수가 들어 해당 글을 작성하게 되었습니다.
해당 글은 Node.js + express로 구축한 API 서버에서 나이스 본인인증 API를 호출하고, 클라이언트와(React) 연동하여 본인인증을 진행하는 과정을 설명하고 있습니다.
나이스 본인인증은 크게 암호화 토큰 요청, 요청 정보 암호화, 나이스 본인인증 서비스 호출, 인증 결과 복호화 등 크게 4단계로 본인인증이 진행됩니다.
먼저 나이스 본인인증 API를 사용하기 전 나이스 API 접근 권한을 확인하기 위한 Access Token이 필요하며, Access Token 요청 API를 호출하여 발급받아야 합니다.
해당 API 호출을 위해 Authorization 헤더 값이 필수적으로 요구되며, 필드 값은 Client ID 값과 Client Secret 값으로 구성됩니다. 요청 값은 나이스 API 이용자 포털 My APP List에서 확인 가능합니다.
Access Token 요청 API를 호출을 통해 응답받은 토큰의 유효 기간은 50년으로 반영구적이며, 해당 토큰을 발급받은 뒤 외부에 유출되지 않도록 안전한 저장소에 저장하여 본인인증 API 호출 시 사용됩니다.
const axios = require('axios');
const qs = require('querystring');
async getAccessToken() {
const clientId = env.process.NICE_CLIENT_ID;
const clientSecret = env.process.NICE_CLIENT_SECRET;
const authorization = Buffer.from(clientId + ':' + clientSecret).toString('base64');
const dataBody = {
'scope': 'default',
'grant_type': 'client_credentials'
};
const response = await axios({
method: 'POST',
url: 'https://svc.niceapi.co.kr:22001/digital/niceid/oauth/oauth/token',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'Authorization': `Basic ${authorization}`
},
data: qs.stringify(dataBody)
});
const token = response.data.dataBody.access_token;
};
클라이언트에서 본인인증 요청이 들어오면 서버에서 나이스 암호화 Token 요청 API를 호출하여 암호화 Token을 요청합니다.
나이스 본인인증 로직을 NiceAuthHandler 클래스에 모두 구현했으며,
암호화 Token 요청 API를 호출하기 위한 Client ID, Prodcut ID, Access Token 값을 생성자에 담아 초기화 했습니다.
const express = require('express');
const app = express();
const uuid4 = require('uuid4');
const NiceAuthHandler = require('./NiceAuthHandler');
app.get('/api/auth/nice', async (req, res) => {
try {
const clientId = process.env.NICE_CLIENT_ID;
const accessToken = process.env.NICE_ACCESS_TOKEN;
const productId = process.env.NICE_PRODUCT_ID;
const niceAuthHandler = new NiceAuthHandler(clientId, accessToken, productId);
// 1. 암호화 토큰 요청
const nowDate = new Date();
// 요청일시(YYYYMMDDHH24MISS)
const reqDtim = niceAuthHandler.formatDate(nowDate);
// 요청시간(초)
const currentTimestamp = Math.floor(nowDate.getTime() / 1000);
// 요청고유번호(30자리)
const reqNo = uuid4().substring(0, 30);
const { siteCode, tokenVal, tokenVersionId } = await niceAuthHandler.getEncryptionToken(reqDtim, currentTimestamp, reqNo);
} catch (error) {
res.status(500).json({ error: error.toString() })
}
});
'use strict'
const axios = require('axios');
module.exports = class NiceAuthHandler {
constructor(clientId, accessToken, productId) {
this.clientId = clientId;
this.accessToken = accessToken;
this.productId = productId;
}
// 날짜 데이터 형변환(YYYYMMDDHH24MISS)
formatDate(date) {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
const hours = String(date.getHours()).padStart(2, '0');
const minutes = String(date.getMinutes()).padStart(2, '0');
const seconds = String(date.getSeconds()).padStart(2, '0');
const formattedDateTime = `${year}${month}${day}${hours}${minutes}${seconds}`;
return formattedDateTime;
}
async getEncryptionToken(reqDtim, currentTimestamp, reqNo) {
try {
const authorization = Buffer.from(this.accessToken + ':' + currentTimestamp + ':' + this.clientId).toString('base64');
const response = await axios({
method: 'POST',
url: 'https://svc.niceapi.co.kr:22001/digital/niceid/api/v1.0/common/crypto/token',
headers: {
'Content-Type': 'application/json',
'Authorization': `bearer ${authorization}`,
'client_id': this.clientId,
'productID': this.productId,
},
data: {
'dataHeader': {
'CNTY_CD': 'ko' // 이용언어 : ko, en, cn
},
'dataBody': {
'req_dtim': reqDtim, // 요청일시
'req_no': reqNo, // 요청고유번호
'enc_mode': '1' // 사용할 암복호화 구분 1 : AES128/CBC/PKCS7
}
}
});
const resData = response.data;
// P000 성공, 이외 모두 오류
if (resData.dataHeader.GW_RSLT_CD !== '1200' && resData.dataBody.rsp_cd !== 'P000') {
throw new Error('Failed to request crypto token', response.data.dataBody.rsp_cd);
}
// 사이트 코드, 서버 토큰 값, 서버 토큰 버전 반환
return {
'siteCode': resData.dataBody.site_code,
'tokenVal': resData.dataBody.token_val,
'tokenVersionId': resData.dataBody.token_version_id
}
} catch (error) {
throw new Error('Failed to get encryption token', error);
}
}
}
암호화 Token 요청 API 요청일시와 요청시간, 응답받은 토큰 값의 조합으로 요청 데이터를 암호화할 대칭키와 무결성키를 생성해 줍니다. 그리고 대칭키는 나이스로부터 반환될 인증 결과를 복호화 하는데 다시 사용되기 때문에 세션에 저장해 줍니다.
app.get('/api/auth/nice', async (req, res) => {
try {
..
// 2. 대칭키 생성
const { key, iv, hmacKey } = niceAuthHandler.generateSymmetricKey(reqDtim, reqNo, tokenVal);
// 대칭키 세션 저장
req.session.nice_key = {
key: key,
iv: iv,
}
}
});
const crypto = require('crypto');
generateSymmetricKey(reqDtim, reqNo, tokenVal) {
try {
if (!reqDtim || !reqNo || !tokenVal) {
throw new Error('Empty parameter');
}
const value = reqDtim.trim() + reqNo.trim() + tokenVal.trim();
// SHA256 암호화 후 Base64 encoding
const hash = crypto.createHash('sha256').update(value).digest('base64');
return {
'key': hash.slice(0, 16), // 앞에서부터 16byte
'iv': hash.slice(-16), // 뒤에서부터 16byte
'hmacKey': hash.slice(0, 32) // 앞에서부터 32byte
}
} catch (error) {
throw new Error('Failed to generate symmetric key', error);
}
}
나이스 본인인증 화면을 호출하기 위한 요청 값을 위에서 생성한 대칭키로 암호화해줍니다. returnurl와 methodtype 경우 나이스 본인인증 처리 결과를 리다이렉트 받을 url과 http method로 해당 값으로 인증 결과를 처리할 API 생성이 필요합니다.
app.get('/api/auth/nice', async (req, res) => {
try {
...
// 3-1. 요청 데이터 암호화
const requestno = reqNo; // 서비스 요청 고유 번호(*)
const returnurl = process.env.NICE_RETURN_URL; // 인증결과를 받을 url(*)
const sitecode = siteCode; // 암호화토큰요청 API 응답 site_code(*)
const authtype = ''; // 인증수단 고정(M:휴대폰인증,C:카드본인확인인증,X:인증서인증,U:공동인증서인증,F:금융인증서인증,S:PASS인증서인증)
const mobileco = ''; // 이통사 우선 선택
const bussinessno = ''; // 사업자번호(법인인증인증에 한함)
const methodtype = 'get'; // 결과 url 전달 시 http method 타입
const popupyn = 'Y'; //
const receivedata = ''; // 인증 후 전달받을 데이터 세팅
const reqData = ({
'requestno': requestno,
'returnurl': returnurl,
'sitecode': sitecode,
'authtype': authtype,
'methodtype': methodtype,
'popupyn': popupyn,
'receivedata': receivedata
});
const encData = niceAuthHandler.encryptData(reqData, key, iv);
}
});
encryptData(data, key, iv) {
try {
if (!data || !key || !iv) {
throw new Error('Empty parameter');
}
const strData = JSON.stringify(data).trim();
// AES128/CBC/PKCS7 암호화
const cipher = crypto.createCipheriv('aes-128-cbc', Buffer.from(key), Buffer.from(iv));
let encrypted = cipher.update(strData, 'utf-8', 'base64');
encrypted += cipher.final('base64');
return encrypted;
} catch (error) {
throw new Error('Failed to encrypt data', error);
}
}
나이스 본인인증 화면을 호출하기 위한 요청 값으로 무결성 체크값 또한 생성해줍니다.
app.get('/api/auth/nice', async (req, res) => {
try {
...
// 3-2. Hmac 무결성 체크 값 생성
const integrityValue = niceAuthHandler.hmac256(encData, hmacKey);
}
});
hmac256(data, hmacKey) {
try {
if (!data || !hmacKey) {
throw new Error('Empty parameter');
}
const hmac = crypto.createHmac('sha256', Buffer.from(hmacKey));
hmac.update(Buffer.from(data));
const integrityValue = hmac.digest().toString('base64');
return integrityValue;
} catch (error) {
throw new Error('Failed to generate HMACSHA256 encrypt', error);
}
}
암호화 토큰 요청 API에서 반환받은 토큰 버전 아이디, 암호화한 요청 데이터, 무결성 값을 클라이언트에 반환합니다. 그러면 클라이언트에서는 해당 데이터를 가지고 pass 본인인증 서비스를 팝업 형태로 호출합니다.
app.get('/api/auth/nice', async (req, res) => {
try {
...
res.json({
'tokenVersionId': tokenVersionId,
'encData': encData,
'integrityValue': integrityValue
});
}
});
<html>
<head>
<title>NICE평가정보 - CheckPlus 안심본인인증 테스트</title>
<script language='javascript'>
window.name ="Parent_window";
function fnPopup(){
window.open('https://nice.checkplus.co.kr/CheckPlusSafeModel/service.cb', 'popupChk', 'width=500, height=550, top=100, left=100, fullscreen=no, menubar=no, status=no, toolbar=no, titlebar=yes, location=no, scrollbar=no');
document.form_chk.action = "https://nice.checkplus.co.kr/CheckPlusSafeModel/service.cb";
document.form_chk.target = "popupChk";
document.form_chk.submit();
}
</script>
</head>
<body>
<!-- 본인인증 서비스 팝업을 호출하기 위해서는 다음과 같은 form이 필요합니다. -->
<form name="form_chk" id="form_chk" method="post">
<input type="hidden" id="m" name="m" value="service"/ >
<input type="hidden" id="token_version_id" name="token_version_id" value=""/ >
<input type="hidden" id="enc_data" name="enc_data" value=""/>
<input type="hidden" id="integrity_value" name="integrity_value" value=""/>
<a href="javascript:fnPopup();"> CheckPlus 안심본인인증 Click</a>
</form>
</body>
</html>
본인인증 서비스를 마치면 나이스는 인증결과를 나이스 본인인증 화면을 호출하기 위한 요청 값에서 입력한 returnurl(/api/auth/nice/callback)로 반환해 줍니다.
서버는 returnurl로 생성된 API에서 암호화된 인증 결과를 반환받으면 세션에 저장한 대칭키로 값을 복호한 후 성공 유무를 확인하여 클라이언트로 다시 redirect 해줍니다.
app.get('/api/auth/nice/callback', async (req, res) => {
try {
const niceAuthHandler = new NiceAuthHandler();
// 세션에 저장된 대칭키
const { key, iv } = req.session.nice_key;
const encData = req.query.enc_data;
const decData = niceAuthHandler.decryptData(encData, key, iv);
res.redirect(301, 'http:locahost:3000/nice_success');
} catch (error) {
res.status(500).json({ error: error.toString() })
}
});
const iconv = require('iconv-lite');
decryptData(data, key, iv) {
try {
if (!data || !key || !iv) {
throw new Error('Empty parameter');
}
const decipher = crypto.createDecipheriv('aes-128-cbc', Buffer.from(key), Buffer.from(iv));
let decrypted = decipher.update(data, 'base64', 'binary');
decrypted += decipher.final('binary');
// 'binary'에서 'euc-kr'로 디코딩
decrypted = iconv.decode(Buffer.from(decrypted, 'binary'), 'euc-kr');
const resData = JSON.parse(decrypted);
return resData;
} catch (error) {
console.log(error);
throw new Error('Failed to decrypt data', error);
}
}
나이스 본인인증 로직이 담겨있는 NiceAuthHandler 클래스의 코드를 알 수 있을까요?