작동 방식
초기 등록시 seed값을 서버와 사용자의 기기에 각각 보관한다.
초기 등록
초기 등록시에는 서버에서 생성한 UUID값을 모바일 기기에서 받기 위해 네트워크 연결이 필요하다.
앱 → 서버 : 앱에서 사용자의 정보를 입력하고 서버로 전송한다
서버 → 앱 : 전송받은 정보를 데이터베이스에 저장하고 랜덤 값을 하나 생성하여 병사 고유 값(Seed 값)으로 저장하고 응답으로 Seed 값을 넘겨준다
앱 : 받은 Seed 값을 디바이스에 저장한다.
이때 Seed 값은 앱, 서버 두 곳에서 보유한다.
반납 과정
이때부터는 네트워크 연결 필요 없이 보유하고 있는 seed값과 현재 시간만을 이용하여 Time-based OTP값을 생성한다.
앱 : 반납 버튼을 누르면 위에 Seed값과 현재 시간 값을 기반으로 TOTP 값을 생성한다.
앱 : TOTP 값과 사용자 정보를 이용하여 QR코드를 생성한다.
앱 → 라즈 → 서버 : QR코드를 인식해 앱에서 생성한 TOTP 값과 사용자 정보를 서버로 전송한다
서버 : 서버에서 앱에서 받은 TOTP 값과, 서버에 저장되어 있는 병사 정보 중 Seed값을 불러와 현재 시간으로 TOTP 생성을 한다. 만약 앱에서 생성한 TOTP값과 서버에서 생성한 TOTP값이 동일하면 인증 완료.
위의 과정에서, 앱에서 TOTP 생성한 시간과 웹에서 TOTP 생성한 시간이 다를 수 있지만 TOTP 생성 과정에서 키가 유지되는 시간을 지정할 수 있기 때문에 문제를 해결할 수 있다.
테스트
간단하게 seed값에 따라 TOTP 값을 반환하는 API를 만들었다. 아래의 seed/ 경로 뒤에 40자리 임의의 숫자(seed 값)을 넣으면 된다.
https://osam.riyenas.dev/api/totp/generate/seed/{seed value}
예시
https://osam.riyenas.dev/api/totp/generate/seed/0123456789012345678901234567890123456789
새로고침을 할 때마다 키 값을 생성하는데 키 유지 시간은 10초로 설정하였다 따라서 10초 마다 TOTP 값이 변경되는 걸 볼 수 있다.
또한 시드 값만 서로 보유하고 있으면 네트워크 연결 없이도 인증을 진행할 수 있다.
시연 영상
Source Code
TOTP.class
package dev.riyenas.osam.domain.auth;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.lang.reflect.UndeclaredThrowableException;
import java.math.BigInteger;
import java.security.GeneralSecurityException;
public class TimeBasedOTP {
private TimeBasedOTP() {}
private static final int[] DIGITS_POWER = {1,10,100,1000,10000,100000,1000000,10000000,100000000};
private static byte[] hmac_sha1(String crypto, byte[] keyBytes, byte[] text) {
try {
Mac hmac;
hmac = Mac.getInstance(crypto);
SecretKeySpec macKey =
new SecretKeySpec(keyBytes, "RAW");
hmac.init(macKey);
return hmac.doFinal(text);
} catch (GeneralSecurityException gse) {
throw new UndeclaredThrowableException(gse);
}
}
private static byte[] hexStrToBytes(String hex) {
byte[] bArray = new BigInteger("10" + hex, 16).toByteArray();
byte[] ret = new byte[bArray.length - 1];
for (int i = 0; i < ret.length ; i++)
ret[i] = bArray[i+1];
return ret;
}
public static String calcSteps(Long time, Long T0, Long X) {
long T = (time - T0) / X;
String steps = "0";
steps = Long.toHexString(T).toUpperCase();
while (steps.length() < 16) {
steps = "0" + steps;
}
return steps;
}
public static String generateTOTP(String key, String time, String returnDigits) {
return generateTOTP(key, time, returnDigits, CryptoType.HmacSHA1);
}
public static String generateTOTP256(String key, String time, String returnDigits) {
return generateTOTP(key, time, returnDigits, CryptoType.HmacSHA256);
}
public static String generateTOTP512(String key, String time, String returnDigits) {
return generateTOTP(key, time, returnDigits, CryptoType.HmacSHA512);
}
public static String generateTOTP(String key, String time, String returnDigits, CryptoType type) {
int codeDigits = Integer.decode(returnDigits).intValue();
String result = null;
byte[] hash;
while(time.length() < 16 ) {
time = "0" + time;
}
byte[] msg = hexStrToBytes(time);
byte[] k = hexStrToBytes(key);
hash = hmac_sha1(type.toString(), k, msg);
int offset = hash[hash.length - 1] & 0xf;
int binary = ((hash[offset] & 0x7f) << 24) |
((hash[offset + 1] & 0xff) << 16) |
((hash[offset + 2] & 0xff) << 8) |
(hash[offset + 3] & 0xff);
int otp = binary % DIGITS_POWER[codeDigits];
result = Integer.toString(otp);
while (result.length() < codeDigits) {
result = "0" + result;
}
return result;
}
}
CryptoType.class
package dev.riyenas.osam.domain.auth;
public enum CryptoType {
HmacSHA1("HmacSHA1"),
HmacSHA256("HmacSHA256"),
HmacSHA512("HMacSHA512");
CryptoType(String crypto) {
this.crypto = crypto;
}
private final String crypto;
public String toString() {
return crypto;
}
}
사용 예시
Calendar time = Calendar.getInstance();
String steps = TimeBasedOTP.calcSteps(time.getTimeInMillis() / 1000, 0L, 10L);
//현재 시간, 0L(초깃값), 10L(10초까지 TOTP 키 유지 시간)
return TimeBasedOTP.generateTOTP(device.getUuid(), steps, "8", CryptoType.HmacSHA512);
//seed(시드값), steps(위에서 계산한 값), TOTP 출력 자리수, 암호화 방식
//이대로면 8자리 TOTP값이 반환됩니다.