Google Authenticator 연동 QR 생성 및 인증 라이브러리 소개 및 사용법

redjen·2021년 10월 18일
0
post-thumbnail

개요

회사에서 작업하는 프로젝트에 otp를 도입해야 해서 구글링을 해 여러 TOTP 생성 라이브러리를 보던 중 문서화가 잘 되어 있고 개인적으로 가장 사용감이 좋았던 라이브러리를 공유하고자 합니다.

소개

https://github.com/samdjstevens/java-totp
Github에 사용법이 잘 나와있지만, 간단히 만든 Java 버전 코드와 Spring에서 어떻게 사용할 수 있는지에 대한 코드를 공유해봅니다.

Plain Java에서 사용 예

먼저 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으로 된 모든 값이 다 들어갈 수 있습니다.

Spring 프로젝트에서 사용 예시

저는 라이브러리가 생성해준 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 코드 생성

이 글을 쓴 이유입니다. QR 생성의 내부 로직이 궁금해져서 뜯어보기로 했습니다.
제 코드에서 파라미터로 이메일 String을 받아 Secret 키를 만드는 로직은 다음과 같습니다.

생각했던 것과 크게 다르지 않았습니다. Secret은 랜덤한 바이트를 생성하고 Apache Codec으로 Base32 인코딩한 값이었습니다.


이렇게 인코딩 된 값은 QRCode 클래스로 빌드 될 때 다음과 같이 uri 형태로 다시 인코딩되고,


다음과 같이 ZxingPngGenerator 클래스의 generate 메소드에 의해 QR코드 Byte URL의 형태로 변하게 됩니다. 기본 사이즈는 350x350이네요.

QR 코드 인증

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으로 바뀌게 됩니다.

다 끝났습니다! 인증 과정을 크게 정리를 하자면 다음과 같습니다.

  1. DefaultTimeProvider에서 Instant.now().getEpochSecond()로 가져온 타임스탬프 정보와 allowedTimePeriodDiscrepancy 변수 값 (기본 1) 을 더한 값을 data 변수에 저장합니다.
  2. Secret 값을 Base32 디코딩 합니다.
  3. HMAC 알고리즘으로 (디코딩 된 Secret 값, data)에 대한 해쉬 값을 생성합니다.
  4. 2번에서 생성된 해쉬 값을 N자리 숫자 String으로 만들어줍니다.
  5. 3번에서 생성된 String과 사용자가 입력한 N자리 숫자 String을 timeSafeStringComparison 메소드로 비교한다.
  6. 1~5 과정을 (Discrepancy 값 * 2 + 1)번 만큼 반복한다. 반복 과정 중 timeSafeStringComparison 메소드가 한 번이라도 true이면 인증은 성공합니다.

결론

오늘은 Samstevens의 totp 라이브러리의 간단한 사용법과, 코드를 간단하게 분석해보았습니다. 시간 정보를 해싱하여 인증에 사용하는 개념이 익숙하지 않아서 관련 자료를 찾아보는데 생각보다 꽤 재미있었습니다. 높은 수준의 사용자 인증이 필요할 때 OTP를 사용하게 되는 경우가 생각보다 종종 있는 것 같습니다. 나중에는 DefaultCodeGenerator와 DefaultCodeVerifier를 사용하지 않고 커스텀한 구현체를 사용해서 저만의 OTP 생성 및 인증 체계를 만들어보고 싶네요. 끝!

profile
make maketh install

3개의 댓글

comment-user-thumbnail
2023년 10월 29일

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.

답글 달기
comment-user-thumbnail
2024년 4월 5일

큰 도움 받았습니다. 감사합니다! 제 블로그에도 이 라이브러리 관련 글을 쓸까 하는데 이 글을 참고 링크로 달아도 될까요?

1개의 답글