少し暇な時間ができたので気まぐれにTOTP(Time-based One-Time Password)認証を実装してみた。
TOTP認証に関しては今更言うまでもないが、時間限定の一時パスコードにより二段階認証である。代表的なアプリとしては、Google AuthenticatorやMicrosoft Authenticatorがある。
念のため、TOTP認証における認証の流れ/アルゴリズムは以下の通り。
TOTP生成アルゴリズム :
- 現在時刻(UNIXタイムスタンプ)/有効期限(秒)の値をクライアント/サーバの双方で保持している秘密鍵でHMAC値を算出
- 「1.」でHMAC値における20文字目の値から、下位4ビットを取り出す。言い換えれば、20文字目のビットに「00001111」とAND演算を実施
- 「2.」で算出した値を10進数変換し、当該値をoffsetとする
- 「3.」で取得したoffsetの値から4文字分「1.」で算出したHMACから切り出す
- 「4.」で取得した値に4文字分(=31ビット)に対して、31ビットなので「0x7FFFFFFF」とAND演算を実施
- 「5.」で算出した値を10のTOTPの桁数分、べき乗した値の剰余計算
※ TOTP桁数に満たない場合は、左埋めゼロパディング処理
TOTP認証の流れ :
- クライアントから端末識別情報とともに、上記アルゴリズムで算出されたTOTPをリクエスト
- サーバー側で端末情報に紐づいた秘密鍵を取得
- サーバー側で同様にTOTPを生成
- 「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);
}