少し暇な時間ができたので気まぐれにTOTP(Time-based One-Time Password)認証を実装してみた。
TOTP認証に関しては今更言うまでもないが、時間限定の一時パスコードにより二段階認証である。大意表的なアプリとしては、Google AuthenticatorやMicrosoft Authenticatorがある。
念のため、TOTP認証における認証の流れ/アルゴリズムは以下の通り。
TOTP生成アルゴリズム :
1. 現在時刻(UNIXタイムスタンプ)/有効期限(秒)の値をクライアント/サーバの双方で保持している秘密鍵でHMAC値を算出
2. 「1.」でHMAC値における20文字目の値から、下位4ビットを取り出す。言い換えれば、20文字目のビットに「00001111」とAND演算を掛ける
3. 「2.」で算出した値を10進数変換し、当該値をoffsetとする
4. 「3.」で取得したoffsetの値から4文字分「1.」で算出したHMACから切り出す
5. 「4.」で取得した値に4文字分(=31ビット)に対して、31ビットなので「0x7FFFFFFF」とAND演算を掛け合わせる
6. 「5.」で算出した値を10のTOTPの桁数分、べき乗した値の剰余計算
TOTP認証の流れ :
1. クライアントから端末識別情報とともに、上記アルゴリズムで算出されたTOTPをリクエスト
2. サーバー側で端末情報に紐づいた秘密鍵を取得
3. サーバー側で同様にTOTPを生成
4. 「1.」でリクエストされたTOTPと、「3.」で生成されたTOTPが一致していれば認証OK
今回、サーバーサイドはSpring Boot、クライアントサイドはNode.jsで実装した。現在時刻取得に関しては、クライアント/サーバー側でずれを無くすためにNTP(ntp.nict.jp)を使用。
① TOTP認証用API
packagecom.example.demo;importjava.io.IOException;importjava.io.UnsupportedEncodingException;importjava.net.InetAddress;importjava.nio.ByteBuffer;importjava.security.InvalidKeyException;importjava.security.NoSuchAlgorithmException;importjavax.crypto.Mac;importjavax.crypto.spec.SecretKeySpec;importorg.apache.commons.net.ntp.NTPUDPClient;importorg.apache.commons.net.ntp.TimeInfo;importorg.jboss.logging.Logger;importorg.springframework.beans.factory.annotation.Autowired;importorg.springframework.http.HttpStatus;importorg.springframework.http.MediaType;importorg.springframework.http.ResponseEntity;importorg.springframework.web.bind.annotation.RequestBody;importorg.springframework.web.bind.annotation.RequestMapping;importorg.springframework.web.bind.annotation.RequestMethod;importorg.springframework.web.bind.annotation.RestController;@RestControllerpublicclassTOTPAuth{/** TOTP取得DAO */@AutowiredprivateTotpDAOtotpDAO;/** ハッシュアルゴリズム */privatefinalstaticStringHMAC_SHA1="HmacSHA1";/** TOTP桁数 */privatefinalstaticintDIGIT=6;/** TOTP有効期限 */privatefinalstaticintSTEP_TIME=30;/** NTPドメイン */privatefinalstaticStringNTP_SERVER="ntp.nict.jp";/** NTPクライアント */privatefinalNTPUDPClientclient=newNTPUDPClient();/** ロガー設定 */privatefinalLoggerlog=Logger.getLogger(TOTPAuth.class);@RequestMapping(value="/totp/check",method=RequestMethod.POST,produces=MediaType.APPLICATION_JSON_VALUE+";charset=UTF-8")publicResponseEntity<TotpCheckResDTO>execute(@RequestBodyTotpLoginFormform)throwsUnsupportedEncodingException{TotpCheckResDTOresDTO=newTotpCheckResDTO();log.debug(TOTPAuth.class.getSimpleName()+"#execute request totp value: "+form.getTotp());SecretDTOsecretInfo=totpDAO.getSecret(form.getDeviceId());byte[]hmacByte=doHmac(secretInfo.getSecret());longtotp=getTOTP(hmacByte);log.debug(TOTPAuth.class.getSimpleName()+"#execute server calculated totp value: "+totp);if(form.getTotp()==totp){resDTO.setResultMsg("TOTP authentication success.");returnnewResponseEntity<TotpCheckResDTO>(resDTO,null,HttpStatus.OK);}resDTO.setResultMsg("TOTP authentication failed.");returnnewResponseEntity<TotpCheckResDTO>(resDTO,null,HttpStatus.FORBIDDEN);}/**
* NTP時刻取得
*
* @return NTP時刻
* @throws IOException
*/privatelonggetNtpTime(){longntpTime;try{this.client.open();InetAddresshost=InetAddress.getByName(NTP_SERVER);TimeInfoinfo=this.client.getTime(host);info.computeDetails();ntpTime=(System.currentTimeMillis()+info.getOffset())/1000L;log.debug(TOTPAuth.class.getSimpleName()+"#getNtpTime current time: "+ntpTime);}catch(IOExceptione){thrownewRuntimeException(e);}returnntpTime;}/**
* HMAC値算出
*
* @param secret
* @return HMAC値
*/publicbyte[]doHmac(Stringsecret){byte[]hmacByte=null;try{Objectmsg=getNtpTime()/STEP_TIME;SecretKeySpecsk=newSecretKeySpec(secret.getBytes(),HMAC_SHA1);Macmac=Mac.getInstance(HMAC_SHA1);mac.init(sk);hmacByte=mac.doFinal(msg.toString().getBytes());}catch(NoSuchAlgorithmExceptione){log.error(TOTPAuth.class.getSimpleName()+"#doHmac NoSuchAlgorithmException occurred, failed to create hmac hash value");thrownewRuntimeException(e);}catch(InvalidKeyExceptione){log.error(TOTPAuth.class.getSimpleName()+"#doHmac InvalidKeyException occurred, failed to create hmac hash value");thrownewRuntimeException(e);}returnhmacByte;}/**
* TOTP取得
*
* @param hmacByte HMAC値
* @return TOTP
*/publiclonggetTOTP(byte[]hmacByte){intoffset=hmacByte[19]&0xF;ByteBufferresult=ByteBuffer.wrap(hmacByte,offset,offset+4);intp=result.getInt()&0x7FFFFFFF;longtotp=(long)(p%Math.pow(10,DIGIT));log.debug(TOTPAuth.class.getSimpleName()+"#getTOTP created totp value: "+totp);returntotp;}}② 秘密鍵取得DAO
・Redisの構成は以下の通り
Key: 端末ID
Field: secret
Value: 秘密鍵
packagecom.example.demo;importjava.util.Map;importorg.springframework.beans.factory.annotation.Autowired;importorg.springframework.data.redis.core.RedisTemplate;importorg.springframework.stereotype.Component;importcom.fasterxml.jackson.core.JsonProcessingException;importcom.fasterxml.jackson.databind.ObjectMapper;@ComponentpublicclassTotpDAO{@AutowiredprivateRedisTemplate<Object,Object>redisTemplate;publicSecretDTOgetSecret(StringdeviceId){ObjectMappermapper=newObjectMapper();SecretDTOresDTO=null;try{Map<Object,Object>secretInfo=redisTemplate.opsForHash().entries(deviceId);resDTO=mapper.readValue(mapper.writeValueAsString(secretInfo),SecretDTO.class);}catch(JsonProcessingExceptione){thrownewRuntimeException(e);}returnresDTO;}}③ RESTクライアント
constcrypto=require('crypto')constntpClient=require('ntp-client');constkey='mysecret'constdigit=6;conststep_time=30;constClient=require('node-rest-client').Client;constclient=newClient();consttotpUrl="http://localhost:8080/totp/check";//TOTP認証実行functionexecute(){newPromise((resolve,reject)=>{ntpClient.getNetworkTime("ntp.nict.jp",123,function(err,date){varcurrentDate=date.getTime();varmsg=Math.floor(currentDate/1000/step_time);console.log("HMAC msg: "+msg);constbufferArray=doHmac(key,msg);lettotp=truncate(bufferArray);console.log("totp: "+totp);checkTotp(totp);})})}//TOTP認証リクエストfunctioncheckTotp(totp){constargs={data:{deviceId:'1a2b3c',totp:totp},headers:{"Content-Type":"application/json"}}client.post(totpUrl,args,function(data,res){console.log(data)})}//JMACのHEX値を取得functiondoHmac(secret,currentTime){consthex=crypto.createHmac('sha1',secret).update(String(currentTime)).digest('hex');returnhex;}//truncate処理functiontruncate(hmac){varhmacArray=hmac.match(/.{2}/g);varhexVal='0x'+hmacArray[19].toString(16).toUpperCase()constoffset=hexVal&0xF;constp='0x'+hmacArray.slice(offset,offset+4).join("").toString(16).toUpperCase();constsNum=p&0x7FFFFFFF;returnsNum%Math.pow(10,digit);}//実行execute();認証が成功すれば以下API応答が返却される。
HMAC msg value: 53713602
totp: 720527
{ resultMsg: 'TOTP authentication success.'}[Finished in 7.125s]