크리에이티브 커먼즈 라이선스
Creative Commons License

   OTP 기능을 구현하라는 미션이 떨어졌고, 힌트로는 구글OTP라는 것이 있다라는 것만 받았다.


   찾아보니 거의 다 "Google Authenticator"라는 앱을 다운받아서 구글 로그인을 할 때에 이용하는 내용이었다.


   뭔가 구글에서 제공하는 API가 있어야 구글앱을 이용해서 개발을 할 수 있을 텐데, 눈을 씻고 찾아봐도 API는 없었다.


   찾다찾다 구글앱의 공식 홈페이지에서 파일들을 다운로드 할 수 있는 곳을 찾았는데, C언어로 되어있고 내가 원하는 것은 아니었다. 아마도 SSH로 접속해서 이걸 설치하고 로그인을 할 때에 사용하는 그런 종류인 듯 싶다.(이곳 참고)


   알고리즘을 중심으로 찾아본 결과, 아마도 IETF에 있는 RFC6238이라는 문서를 기반으로 구글앱이 이와 같은 알고리즘으로 구현을 해놓은 것 같다.


   따져보니 이것과 똑같은 알고리즘으로 Key/바코드를 만들어내고 그것을 검증하는 코드를 짜면 완벽하게 구글앱을 사용하는 OTP 기능을 만들어낼 수 있을 것 같았다.


   다른 사람들이 만들어놓은 자바 코드를 찾지 못했을 경우, 최악의 경우엔 정말 그렇게 해보려고 했다.


   국내에선 파이썬 코드를 쉽게 찾을 수 있었지만 자바가 아니어서 아무 쓸모가 없었다.


   지푸라기라도 잡는 심정으로 온갖 검색어 조합을 동원하여 외국 사이트를 이 잡듯이 뒤진 결과,


   다행히 누군가 자바스크립트로 구현해놓은 코드를 발견했다.


   미칠듯이 기뻤다. 속으로 감동의 눈물을 흘릴 정도였다. 그러나 의존하는 라이브러리 파일들을 제대로 다 링크해주고, 코드도 이대로 했음에도 불구하고 어떤 연유에서인지 내가 테스트하는 프로젝트에서는 작동하지 않았다. 


   이내 포기하고 다른 코드를 찾아 헤맸는데, 정말 다행스럽게도 자바로 구현한 코드를 찾았냈다!!!


   그런데 이것도 코드에서 sercretSize, numOfScratchCodes, scratchCodeSize를 몇으로 설정해줘야 하는지 별 말도 없고 그닥 친절하지 않아서 적용에 실패했다.


   더 찾아보니 이 코드를 기반으로 복잡하게 만들어놓은 코드를 발견했는데, 이건 더 머리가 터질 것 같았다. 하루 반 동안 붙잡고 하다가 GG.


   결국 다시 간단한 자바 코드로 돌아가서 붙잡고 요리조리 해본 결과, 성공!!!!


   서론이 길었다.;;


   아무튼 총 4일 동안 끙끙대서 성공한 결과물을 아래에 설명하고자 한다. 비록 내가 처음부터 날코딩한 것은 아니지만, OTP를 구현하려 나처럼 인터넷 망망대해를 떠돌고 있을 불쌍한 중생들을 위해 올려놓는다.




   먼저 OTP란 무엇이고 어떻게 돌아가는지부터 알고 들어가야 하니 간단하게 짚고 넘어가겠다.



   1. OTP란?


   One Time Password의 약자로, 우리말로 하면 일회용 비밀번호라 할 수 있다. 일회성이라는 특징 때문에 일반 비밀번호 입력이나 공인인증서 이용보다도 더 안전한 방법으로 알려져있다. 주로 금융권이나 일반 웹사이트 2차 로그인 인증으로 많이 활용되고 있다. OTP의 종류에는 원리에 따라 S/KEY방식, 시간 동기화 방식, 챌린지 응답 방식, 이벤트 동기화 방식 등이 있다. (좀더 자세한 내용을 알고 싶다면, 위키백과 참조) 여기서는 이중 시간 동기화 방식을 사용한 Google Authenticator라는 앱으로 TOTP 인증을 구현해보도록 하겠다.




   2. TOTP((Time-based One Time Password)의 원리


   많은 사람들이 오해하는 게 있는데(나 또한 그랬다), OTP 기기와 서버가 통신하는 줄 착각한다. 실상은 그렇지 않다. OTP 기기와 서버는 모두 같은 알고리즘을 바탕으로 하기 때문에 통신이 필요치 않다. 원리는 간단하다. 서버쪽에서 해당 알고리즘으로 Key나 바코드 주소를 생성해주면 그걸 OTP 기기에 입력해준다. 그러면 기기에서는 그 Key나 바코드를 기준으로 하여 30~60초 마다 계속하여 새로운 일회용 비밀번호를 생성해낸다. 그 일회용 비밀번호를 입력하여 서버로 전송하면 서버에서 그 비밀번호가 맞는지 알고리즘으로 확인하는 방식이다. 여기서 구글앱을 이용한다는 것은 OTP 기기 대신에 스마트폰에 있는 Google Authenticator라는 앱으로 대체한다는 것이다.


   뭔가 복잡해보이지만, 이해하고나면 쉽다.




   3. Java로 구현해보기.


   자 그럼 Java로 직접해보자. 아래는 JSP를 이용해서 웹사이트에 구현하다는 가정 하에 진행하였다.


   먼저 commons-codec.jar 파일을 라이브러리에 추가해준다.



 commons-codec-1.9.jar



   또 만약 자바 버전이 1.6 이하일 경우에는 아래의 파일을 다운받아서 라이브러리 추가해준다.



 security.jar



   그리고 자신의 스마트폰에 Google Authenticator 앱을 다운받아 설치해놓는다.

   


   다음은 실제 코드다.


   2차 인증 로그인 리퀘스트가 들어올 시, 사용자명과 계정명을 받아서 키를 생성할 부분.


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
import java.io.IOException;
import java.util.Arrays;
import java.util.Random;
 
import javax.servlet.ServletException;
import javax.servlet.http.HttpServlet;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
 
import org.apache.commons.codec.binary.Base32;
 
public class OtpServlet extends HttpServlet {
 
    @Override
    protected void service(HttpServletRequest req, HttpServletResponse res)
            throws ServletException, IOException {
         
        // Allocating the buffer
//      byte[] buffer = new byte[secretSize + numOfScratchCodes * scratchCodeSize];
        byte[] buffer = new byte[5 5 5];
         
        // Filling the buffer with random numbers.
        // Notice: you want to reuse the same random generator
        // while generating larger random number sequences.
        new Random().nextBytes(buffer);
 
        // Getting the key and converting it to Base32
        Base32 codec = new Base32();
//      byte[] secretKey = Arrays.copyOf(buffer, secretSize);
        byte[] secretKey = Arrays.copyOf(buffer, 5);
        byte[] bEncodedKey = codec.encode(secretKey);
         
        // 생성된 Key!
        String encodedKey = new String(bEncodedKey);
         
        System.out.println("encodedKey : " + encodedKey);
         
//      String url = getQRBarcodeURL(userName, hostName, secretKeyStr);
        // userName과 hostName은 변수로 받아서 넣어야 하지만, 여기선 테스트를 위해 하드코딩 해줬다.
        String url = getQRBarcodeURL("hj""company.com", encodedKey); // 생성된 바코드 주소!
        System.out.println("URL : " + url);
         
        String view = "/WEB-INF/view/otpTest.jsp";
         
        req.setAttribute("encodedKey", encodedKey);
        req.setAttribute("url", url);
         
        req.getRequestDispatcher(view).forward(req, res);
         
    }
     
    public static String getQRBarcodeURL(String user, String host, String secret) {
         
        return String.format(format, user, host, secret);
    }
     
}




   otpTest.jsp

1
2
3
4
5
6
7
8
9
10
당신의 키는 → ${encodedKey } 입니다. <br>
당신의 바코드 주소는 → ${url } 입니다. <br><br>
 
<form action="<%=request.getContextPath() %>/otpTestResult.ok" method="get">
    code : <input type="text" name="user_code"><br><br>
     
    <input type="hidden" name="encodedKey" value="${encodedKey }" readonly="readonly"><br><br>
    <input type="submit" value="전송!">
     
</form>


   키나 바코드를 이용해 구글앱에서 항목을 추가한 뒤, 거기서 생성되는 일회용 비밀번호를 code 부분에 써주고 전송을 클릭한다.



   code 부분에 저 숫자를 넣어주면 된다.




   전송 리퀘스트를 받는 부분에서 해당 코드가 맞는지 여부를 검사한다.


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
import java.io.IOException;
import java.security.InvalidKeyException;
import java.security.NoSuchAlgorithmException;
import java.util.Date;
 
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServlet;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
 
import org.apache.commons.codec.binary.Base32;
 
public class OtpResultServlet extends HttpServlet {
 
    @Override
    protected void service(HttpServletRequest req, HttpServletResponse res)
            throws ServletException, IOException {
         
        String user_codeStr = req.getParameter("user_code");
        long user_code = Integer.parseInt(user_codeStr);
        String encodedKey = req.getParameter("encodedKey");
        long l = new Date().getTime();
        long ll =  l / 30000;
         
        boolean check_code = false;
        try {
            // 키, 코드, 시간으로 일회용 비밀번호가 맞는지 일치 여부 확인.
            check_code = check_code(encodedKey, user_code, ll);
        catch (InvalidKeyException e) {
            e.printStackTrace();
        catch (NoSuchAlgorithmException e) {
            e.printStackTrace();
        }
         
        // 일치한다면 true.
        System.out.println("check_code : " + check_code);
         
    }
 
    private static boolean check_code(String secret, long code, long t) throws NoSuchAlgorithmException, InvalidKeyException {
        Base32 codec = new Base32();
        byte[] decodedKey = codec.decode(secret);
 
        // Window is used to check codes generated in the near past.
        // You can use this value to tune how far you're willing to go.
        int window = 3;
        for (int i = -window; i <= window; ++i) {
            long hash = verify_code(decodedKey, t + i);
 
            if (hash == code) {
                return true;
            }
        }
 
        // The validation code is invalid.
        return false;
    }
     
    private static int verify_code(byte[] key, long t)
            throws NoSuchAlgorithmException, InvalidKeyException {
        byte[] data = new byte[8];
        long value = t;
        for (int i = 8; i-- > 0; value >>>= 8) {
            data[i] = (byte) value;
        }
 
        SecretKeySpec signKey = new SecretKeySpec(key, "HmacSHA1");
        Mac mac = Mac.getInstance("HmacSHA1");
        mac.init(signKey);
        byte[] hash = mac.doFinal(data);
 
        int offset = hash[20 1] & 0xF;
 
        // We're using a long because Java hasn't got unsigned int.
        long truncatedHash = 0;
        for (int i = 0; i < 4; ++i) {
            truncatedHash <<= 8;
            // We are dealing with signed bytes:
            // we just keep the first byte.
            truncatedHash |= (hash[offset + i] & 0xFF);
        }
 
        truncatedHash &= 0x7FFFFFFF;
        truncatedHash %= 1000000;
 
        return (int) truncatedHash;
    }
     
}


   시간이 흘러서 일회용 비밀번호가 계속 바뀌어도 바뀐 일회용 비밀번호를 입력하면 완벽하게 true를 뱉어냄을 확인할 수 있다.


   지금까지 구글앱을 이용한 OTP 기능을 어떻게 구현하는지 알아봤다.


   사실 구글앱을 이용하는 방법 말고도 오픈소스를 이용한다든지, 돈으로 떼우는 방법 등등..  방법은 많다.


   아무쪼록 구글앱을 이용한 OTP 기능 개발을 찾아헤매는 사람들에게 도움이 되었길 바란다.

저작자 표시
신고
Posted by gofly


티스토리 툴바