3
2

More than 3 years have passed since last update.

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

Last updated at Posted at 2021-01-23

少し暇な時間ができたので気まぐれに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

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: 秘密鍵

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クライアント

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);
}
3
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
3
2