회사에서 작업하는 프로젝트에 otp를 도입해야 해서 구글링을 해 여러 TOTP 생성 라이브러리를 보던 중 문서화가 잘 되어 있고 개인적으로 가장 사용감이 좋았던 라이브러리를 공유하고자 합니다.
https://github.com/samdjstevens/java-totp
Github에 사용법이 잘 나와있지만, 간단히 만든 Java 버전 코드와 Spring에서 어떻게 사용할 수 있는지에 대한 코드를 공유해봅니다.
먼저 Plain Java에서 동작하는 코드입니다. VSCode에서 실행했으며, 다음의 Dependency들을 요구합니다. jar 파일들을 다운 받아 프로젝트 의존성에 추가해줘야 동작합니다.
Samstevens TOTP : 해당 라이브러리입니다.
Google Zxing Core : Google Zxing 바코드 생성 라이브러리입니다.
Google Zxing SE : Google Zxing 바코드 생성 라이브러리 SE Specific Extension입니다.
Apache Codec : Apache Common Codec 라이브러리입니다. Base32 변환을 위해 사용합니다.
<Totp.java>
import dev.samstevens.totp.secret.DefaultSecretGenerator;
import dev.samstevens.totp.secret.SecretGenerator;
import dev.samstevens.totp.time.SystemTimeProvider;
import dev.samstevens.totp.time.TimeProvider;
import dev.samstevens.totp.code.CodeGenerator;
import dev.samstevens.totp.code.CodeVerifier;
import dev.samstevens.totp.code.DefaultCodeGenerator;
import dev.samstevens.totp.code.DefaultCodeVerifier;
import dev.samstevens.totp.code.HashingAlgorithm;
import dev.samstevens.totp.exceptions.QrGenerationException;
import dev.samstevens.totp.qr.QrData;
import dev.samstevens.totp.qr.QrGenerator;
import dev.samstevens.totp.qr.ZxingPngQrGenerator;
import static dev.samstevens.totp.util.Utils.getDataUriForImage;
class Totp {
private String dataUrl;
private String secret;
public String generateSecret (String email) {
SecretGenerator secretGenerator = new DefaultSecretGenerator();
secret = secretGenerator.generate();
QrData data = new QrData.Builder()
.label(email)
.secret(secret)
.issuer("TOTP Test")
.algorithm(HashingAlgorithm.SHA1)
.digits(6)
.period(30)
.build();
byte[] imageData;
QrGenerator generator = new ZxingPngQrGenerator();
try {
imageData = generator.generate(data);
}
catch (QrGenerationException e) {
e.printStackTrace();
return "";
}
String mimeType = generator.getImageMimeType();
dataUrl = getDataUriForImage(imageData, mimeType);
return secret;
}
public boolean verifyQR (String key) {
TimeProvider timeProvider = new SystemTimeProvider();
CodeGenerator codeGenerator = new DefaultCodeGenerator();
CodeVerifier verifier = new DefaultCodeVerifier(codeGenerator, timeProvider);
return verifier.isValidCode(secret, key);
}
public String getDataUrl() {
return dataUrl;
}
}
<Main.java>
import java.util.Scanner;
public class Main {
public static void main(String[] args) {
Totp totp = new Totp();
Scanner sc = new Scanner(System.in);
System.out.print("Input your email : ");
String yourEmail = sc.nextLine();
String secret = totp.generateSecret(yourEmail);
System.out.println("your secret code : " + secret);
System.out.println("your QR url : " + totp.getDataUrl());
boolean res = false;
while(!res) {
System.out.print("Input Generated Code : ");
String verifyCode = sc.nextLine();
res = totp.verifyQR(verifyCode);
System.out.println("result : " + res);
}
}
}
프로그램을 실행한 결과는 다음과 같습니다. String으로 된 QR Url을 주소창에 붙여넣으면 다음과 같은 QR 코드가 보입니다.
저는 Email을 Label로 사용했지만 커스텀하기 나름입니다. String으로 된 모든 값이 다 들어갈 수 있습니다.
저는 라이브러리가 생성해준 Secret Key를 MySQL에 저장하고 이 키 값을 인증 API 호출 시 읽어와 Verify 한 결과를 리턴하는 식으로 사용했습니다. 생성한 QR은 JSP의 Model로 addAttribute 해주었고, <img>
태그에 prop
함수로 src
속성을 넣어주어 OTP 등록 화면에 QR코드가 바로 나타나게 해주었습니다. 작동 로직은 앞서 소개했던 Github의 코드와 거의 유사하게 사용했고, 키 생성 커스텀 방법도 어렵지 않아 기존 프로젝트에 import 하기 편했습니다.
<QR 키 생성 API>
@PostMapping(value = "/otp/generate", produces = { MediaType.APPLICATION_JSON_UTF8_VALUE })
public ResponseEntity<Map<String, Object>> createOtp(Locale locale, @RequestParam HashMap<String, String> paramMap, HttpServletRequest request, HttpServletResponse response, HttpSession session) {
String usrEmail = paramMap.get("email");
Map<String, Object> resultMap = new HashMap<String, Object>();
MemberVO updateMember = (MemberVO) session.getAttribute("member");
SecretGenerator secretGenerator = new DefaultSecretGenerator();
String secret = secretGenerator.generate();
QrData data = new QrData.Builder()
.label(usrEmail)
.secret(secret)
.issuer("Application Name")
.algorithm(HashingAlgorithm.SHA1)
.digits(6)
.period(30)
.build();
QrGenerator qrGenerator = new ZxingPngQrGenerator();
byte[] imageData;
try {
imageData = qrGenerator.generate(data);
}
catch (QrGenerationException e) {
// QR 생성 에러 예외 처리
return new ResponseEntity<>(resultMap, HttpStatus.BAD_REQUEST);
}
String mimeType = qrGenerator.getImageMimeType();
String dataUri = getDataUriForImage(imageData, mimeType);
resultMap.put("encodedKey", secret);
resultMap.put("url", dataUri);
return new ResponseEntity<>(resultMap, HttpStatus.OK);
}
<QR 인증 API>
@PostMapping(value = "/otp/verify", produces = { MediaType.APPLICATION_JSON_UTF8_VALUE })
public boolean verifyOtp(Locale locale, @RequestParam HashMap<String, String> paramMap, HttpServletRequest request, HttpServletResponse response, HttpSession session) {
String encodedKey = paramMap.get("encodedKey");
String otpNumber = paramMap.get("otpNumber");
TimeProvider timeProvider = new SystemTimeProvider();
CodeGenerator codeGenerator = new DefaultCodeGenerator();
CodeVerifier verifier = new DefaultCodeVerifier(codeGenerator, timeProvider);
boolean check = verifier.isValidCode(encodedKey, otpNumber);
if (check) {
// OTP가 맞았을 때의 로직.
}
return check;
}
저는 OTP 인증 API에서 업데이트 된 OTP Secret 값을 DB에 Insert 하고 Session 정보에 OTP 인증 여부를 저장해 로그아웃하지 않았을 때 다시 OTP를 인증하지 않도록 했습니다. OTP 키를 분실하였을 때 관리자에게 OTP 초기화를 요청하라는 모달을 띄우도록 하였습니다. 관리자는 사용자의 OTP 정보를 삭제할 수 있게 인증된 OTP 정보를 삭제하는 API도 만들어주어 프로젝트에 사용하였습니다.
이 글을 쓴 이유입니다. QR 생성의 내부 로직이 궁금해져서 뜯어보기로 했습니다.
제 코드에서 파라미터로 이메일 String을 받아 Secret 키를 만드는 로직은 다음과 같습니다.
생각했던 것과 크게 다르지 않았습니다. Secret은 랜덤한 바이트를 생성하고 Apache Codec으로 Base32 인코딩한 값이었습니다.
이렇게 인코딩 된 값은 QRCode 클래스로 빌드 될 때 다음과 같이 uri 형태로 다시 인코딩되고,
다음과 같이 ZxingPngGenerator 클래스의 generate 메소드에 의해 QR코드 Byte URL의 형태로 변하게 됩니다. 기본 사이즈는 350x350이네요.
OTP가 맞는지 인증하는 코드는 훨씬 더 복잡했습니다. 😣
처음엔 QR 코드 생성 단계에서 시간 정보가 들어가야 하는 것이 아닌가? 싶었는데 코드를 보시면 아시겠지만 QR 생성 단계에서는 그 어떤 시간 정보도 들어가지 않았습니다.
그럼 인증은 어떻게 할까요? 기본적으로 가져온 정보는 두 가지입니다. 사용자로부터 입력받은 6자리의 숫자 코드와 DB에 저장되어 있는 개별 사용자의 QR Secret 코드입니다.
먼저 OTP 인증 코드입니다.먼저 DefaultTimeProvider는 Java 8의 Instant.now().getEpochSecond()
로 타임스탬프 정보를 생성해줍니다. 타임스탬프 정보를 OTP 유효 시간인 30초로 나눈 값을 currentBucket 변수에 저장합니다. 그 다음 시간의 사소한 오차를 보정해주기 위해 다음과 같이 for문을 사용해 오차를 보정해주는 checkCode를 실행합니다. 기본 allowedTimePeriodDiscrepancy 값은 1이기 때문에, currentBucket-1, currentBucket, currentBucket+1 값에 대해 checkCode를 실행하여 하나라도 True일 경우 True를 리턴합니다.
기본 3번의 checkCode 메소드를 실행하여 체크를 하는 것을 알았으니, checkCode는 어떻게 동작할까요?
checkCode는 timeSafeStringComparison 메소드의 결과 값을 리턴합니다. 두 String의 Byte값을 먼저 완전 탐색 XOR 연산하고, 다시 result 변수에 Or 연산하여 리턴하는 모습입니다. 즉 두 String 값이 완전히 일치하는지에 대한 검사를 수행한다고 할 수 있겠습니다.
인자로 들어가는 두 String이 어떤 녀석들인지 봤더니, actualCode와 code라는 이름이 보입니다. code는 사용자로부터 입력받은 6자리 숫자 String입니다. 그렇다면 actualCode는 secret 값을 인자로 받아 생성되는 6자리 숫자 String이라고 추론할 수 있습니다.
DefaultCodeGenerator의 generate 메소드와 generateHash 메소드입니다. secret 값을 인자로 받은 GenerateHash 메소드는 data 값에 다음과 같은 값을 저장합니다.
그 다음, Secret을 Base32 디코딩 한 값을 Javax.crypto의 MAC (Message Authenticaton Code) 클래스와 SecretKeySpec 클래스에 의해 HMAC Algorithm으로 Construct된 Key로 MAC 인스턴스를 초기화하고 인스턴스의 해쉬 값을 반환합니다. HMAC과 MAC에 대해 잘 정리되어 있는 글이 있어 공유합니다. HMAC? MAC?이 뭐에요?
어렵게 막 써놨었지만 generateHash 메소드가 하는 일을 정리하자면, 'Secret Key 값을 Base32 디코딩 한 뒤 HMAC 알고리즘에 의거한 Hash값을 생성한다' 고 요약할 수 있겠습니다. 써놓고 보니 당연한 거네요.. 다만 Java MAC 클래스와 SecretKeySpec 클래스를 사용한다는 점!
이렇게 생성된 해쉬 값은 getDigitsFromHash 메소드에 의해 n자리의 숫자 String으로 바뀌게 됩니다.
다 끝났습니다! 인증 과정을 크게 정리를 하자면 다음과 같습니다.
- DefaultTimeProvider에서 Instant.now().getEpochSecond()로 가져온 타임스탬프 정보와 allowedTimePeriodDiscrepancy 변수 값 (기본 1) 을 더한 값을 data 변수에 저장합니다.
- Secret 값을 Base32 디코딩 합니다.
- HMAC 알고리즘으로 (디코딩 된 Secret 값, data)에 대한 해쉬 값을 생성합니다.
- 2번에서 생성된 해쉬 값을 N자리 숫자 String으로 만들어줍니다.
- 3번에서 생성된 String과 사용자가 입력한 N자리 숫자 String을 timeSafeStringComparison 메소드로 비교한다.
- 1~5 과정을 (Discrepancy 값 * 2 + 1)번 만큼 반복한다. 반복 과정 중 timeSafeStringComparison 메소드가 한 번이라도 true이면 인증은 성공합니다.
오늘은 Samstevens의 totp 라이브러리의 간단한 사용법과, 코드를 간단하게 분석해보았습니다. 시간 정보를 해싱하여 인증에 사용하는 개념이 익숙하지 않아서 관련 자료를 찾아보는데 생각보다 꽤 재미있었습니다. 높은 수준의 사용자 인증이 필요할 때 OTP를 사용하게 되는 경우가 생각보다 종종 있는 것 같습니다. 나중에는 DefaultCodeGenerator와 DefaultCodeVerifier를 사용하지 않고 커스텀한 구현체를 사용해서 저만의 OTP 생성 및 인증 체계를 만들어보고 싶네요. 끝!
I've been using OTP Generator: HOTP, TOTP, OCRA,EVV : HOTP, TOTP, OCRA,EVV for some time now, and it's become an indispensable tool in my online security toolkit. The convenience and added layer of security it provides are truly commendable.