Java(Spring Boot)でTOTP認証を実装してみた

少し暇な時間ができたので気まぐれに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桁数に満たない場合は、左埋めゼロパディング処理


**TOTP認証の流れ :**
1. クライアントから端末識別情報とともに、上記アルゴリズムで算出されたTOTPをリクエスト
2. サーバー側で端末情報に紐づいた秘密鍵を取得
3. サーバー側で同様にTOTPを生成
4. 「1.」でリクエストされたTOTPと、「3.」で生成されたTOTPが一致していれば認証OK

***① TOTP認証用API***

```java
package com.example.demo.totp;

import java.io.IOException;
import java.io.UnsupportedEncodingException;
import java.net.InetAddress;
import java.nio.ByteBuffer;
import java.security.InvalidKeyException;
import java.security.NoSuchAlgorithmException;

import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;

import org.apache.commons.net.ntp.NTPUDPClient;
import org.apache.commons.net.ntp.TimeInfo;
import org.jboss.logging.Logger;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.bind.annotation.RestController;

@RestController
public class TOTPAuth {
    
    /** TOTP取得DAO */
    @Autowired
    private TotpDAO totpDAO;
    
    /** ハッシュアルゴリズム */
    private final static String HMAC_SHA256 = "HmacSHA256";
    
    /** TOTP桁数 */
    private final static int DIGIT = 6;
    
    /** TOTP有効期限 */
    private final static int STEP_TIME = 30;
    
    /** NTPドメイン */
    private final static String NTP_SERVER = "ntp.nict.jp";
    
    /** NTPクライアント */
    private final NTPUDPClient client = new NTPUDPClient();
    
    /** ロガー設定 */
    private final Logger log = Logger.getLogger(TOTPAuth.class);
    
    @RequestMapping(value = "/totp/check", method = RequestMethod.POST, produces = MediaType.APPLICATION_JSON_VALUE
            + ";charset=UTF-8")
    public ResponseEntity<TotpCheckResDTO> execute(@RequestBody TotpLoginForm form)
            throws UnsupportedEncodingException {
        
        TotpCheckResDTO resDTO = new TotpCheckResDTO();
        
        log.debug(TOTPAuth.class.getSimpleName() + "#execute request totp value: " + form.getTotp());
        SecretDTO secretInfo = totpDAO.getSecret(form.getDeviceId());
        byte[] hmacByte = doHmac(secretInfo.getSecret());
        String totp = truncate(hmacByte);
        log.debug(TOTPAuth.class.getSimpleName() + "#execute server calculated totp value: " + totp);
        if (form.getTotp().equals(totp)) {
            resDTO.setResultMsg("TOTP authentication success.");
            return new ResponseEntity<TotpCheckResDTO>(resDTO, null, HttpStatus.OK);
        }
        
        resDTO.setResultMsg("TOTP authentication failed.");
        return new ResponseEntity<TotpCheckResDTO>(resDTO, null, HttpStatus.FORBIDDEN);
        
    }
    
    /**
     * NTP時刻取得
     * 
     * @return NTP時刻
     * @throws IOException
     */
    private long getNtpTime() {
        
        long ntpTime;
        
        try {
            this.client.open();
            InetAddress host = InetAddress.getByName(NTP_SERVER);
            TimeInfo info = this.client.getTime(host);
            info.computeDetails();
            ntpTime = (System.currentTimeMillis() + info.getOffset()) / 1000L;
            log.debug(TOTPAuth.class.getSimpleName() + "#getNtpTime current time: " + ntpTime);
        } catch (IOException e) {
            throw new RuntimeException(e);
        }
        
        return ntpTime;
    }
    
    /**
     * HMAC値算出
     * 
     * @param secret
     * @return HMAC値
     */
    public byte[] doHmac(String secret) {
        
        byte[] hmacByte = null;
        try {
            Object msg = getNtpTime() / STEP_TIME;
            SecretKeySpec sk = new SecretKeySpec(secret.getBytes(), HMAC_SHA256);
            Mac mac = Mac.getInstance(HMAC_SHA256);
            mac.init(sk);
            hmacByte = mac.doFinal(msg.toString().getBytes());
            
        } catch (NoSuchAlgorithmException e) {
            log.error(TOTPAuth.class.getSimpleName()
                    + "#doHmac NoSuchAlgorithmException occurred, failed to create hmac hash value");
            throw new RuntimeException(e);
        } catch (InvalidKeyException e) {
            log.error(TOTPAuth.class.getSimpleName()
                    + "#doHmac InvalidKeyException occurred, failed to create hmac hash value");
            throw new RuntimeException(e);
        }
        
        return hmacByte;
    }
    
    /**
     * TOTP取得
     * 
     * @param hmacByte HMAC値
     * @return TOTP
     */
    public String truncate(byte[] hmacByte) {
        
        int offset = hmacByte[hmacByte.length - 1] & 0xF;
        ByteBuffer result = ByteBuffer.wrap(hmacByte, offset, offset + 4);
        int p = result.getInt() & 0x7FFFFFFF;
        String totp = String.valueOf((long) (p % Math.pow(10, DIGIT)));
        if (totp.length() < DIGIT) {
            totp = String.format("%06d", Integer.parseInt(totp));
        }
        
        return totp;
        
    }
    
}
```

***② 秘密鍵取得DAO***

・Redisの構成は以下の通り
  Key: 端末ID
  Field: secret
  Value: 秘密鍵

```java
package com.example.demo;

import java.util.Map;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Component;

import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;

@Component
public class TotpDAO {
    
    @Autowired
    private RedisTemplate<Object, Object> redisTemplate;
    
    public SecretDTO getSecret(String deviceId) {
        
        ObjectMapper mapper = new ObjectMapper();
        
        SecretDTO resDTO = null;
        try {
            Map<Object, Object> secretInfo = redisTemplate.opsForHash().entries(deviceId);
            resDTO = mapper.readValue(mapper.writeValueAsString(secretInfo), SecretDTO.class);
        } catch (JsonProcessingException e) {
            throw new RuntimeException(e);
        }       
        return resDTO;       
    }   
}
```

***③ RESTクライアント***

```javascript
function getNtpTime() {

  	var ntpUrl = "https://ntp-a1.nict.go.jp/cgi-bin/json";
    var ntpTime;
	// 現在時刻取得
  		$.ajax({
  			url: ntpUrl,
  			type: 'GET',
			async: false
  		})
  		.done((data) => {
			var jsonData = JSON.stringify(data);
			json = JSON.parse(jsonData);
			ntpTime = Math.floor(json["st"]);

  		})
  		.fail((data) => {
			alert("failed to totp authentication.");
  		})

		return ntpTime;
}

function totpLogin() {

	var totpLoginUrl = "http://localhost:8080/totp/check";
	var totp = document.getElementById("totpId").innerHTML;

		$.ajax({
			url: totpLoginUrl,
			type: 'POST',
			data: JSON.stringify({
				'deviceId': '1a2b3c',
				'totp': totp
			}),
			async: false,
			contentType: "application/json; charset=utf-8"
		})
		.done((data) => {
				alert("SUCCESS TOTP authentication !!");	
		})
		.fail((data, status, error) => {
			var dataJson = JSON.stringify(data);
			if(data.status == 403){
				var errMsg = JSON.parse(dataJson)["resultMsg"];
				$("#span1_1").text(errMsg);
				}	
		})
		return false;
	}
	
// HMAC生成
function createHmac(msg){
	console.log("msg: " + msg);
	var hashHex = CryptoJS.enc.Hex.stringify(CryptoJS.HmacSHA256(msg.toString(), "mysecret"));
	console.log("hex value: " + hashHex);
	return hashHex;
}

//truncate処理
function truncate(hmac){

  var digit = 6;
  var hmacArray = hmac.match(/.{2}/g);
  var hexVal = '0x' + hmacArray[hmacArray.length-1].toString(16).toUpperCase();
  const offset = hexVal & 0xF;
  console.log("offset: " + offset);
  const p = '0x' + hmacArray.slice(offset, offset+4).join("").toString(16).toUpperCase();
  const sNum = p & 0x7FFFFFFF;
  var totp = sNum % Math.pow(10, digit);
  if(totp.toString.length < digit){
	totp = zeroPadding(totp, digit);
  }
  return totp;
}

// TOTP生成
function createTotp(){
	
	var stepTime = 30;
	var currentTime = Math.floor(getNtpTime()  / stepTime);
	var hmacHex = createHmac(currentTime);
	var totp = truncate(hmacHex);
	
	var date = new Date();
	document.getElementById("totpId").innerHTML = totp;
	document.getElementById("nowId").innerHTML = date.toLocaleString();

}

function zeroPadding(totp, digit){
	return (Array(digit).join('0') + totp).slice(-digit);
}
```