반응형

📝OTP

one time password로 무작위 생성된 일회용 패스워드로 인증하는 방식을 의미합니다.

 

 

ℹ️ OTP의 동작과정

  1. 암호화 키를 만든다. (아이디 + 비밀번호를 합쳐서 암호화 하든가 등...)
  2. DB에 암호화키를 저장하고 암호화키 기반으로 Google에서 인증 QR을 만든다.
  3. 인증 QR을 통해 앱에 등록한다.
  4. 앱에서 인증 QR기반으로 랜덤 6자리 숫자키가 만들어진다.
  5. 사용자가 입력한 6자리 숫자키와 DB에 저장된 암호화키와 동일한지 체크한다

 

 

<!DOCTYPE html>
<html lang="en">
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.0.2/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-EVSTQN3/azprG1Anm3QDgpJLIm9Nao0Yz1ztcQTwFspd3yD65VohhpuuCOmLASjC" crossorigin="anonymous">
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.0.2/dist/js/bootstrap.bundle.min.js" integrity="sha384-MrcW6ZMFYlzcLA8Nl+NtUVF0sA7MsXsP1UyJoMp4YLEuNSfAP+JcXn/tWtIaxVXM" crossorigin="anonymous"></script>
<script src="https://code.jquery.com/jquery-3.6.3.js" integrity="sha256-nQLuAZGRRcILA+6dMBOvcRh5Pe310sBpanc6+QBmyVM=" crossorigin="anonymous"></script>

<head>
	<meta charset="UTF-8">
	<meta http-equiv="X-UA-Compatible" content="IE=edge">
	<meta name="viewport" content="width=device-width, initial-scale=1.0">
	<title>Document</title>
</head>
<body>
<div class="container mt-5">

<div class="mb-3">
	<label for="exampleInputEmail1" class="form-label">Email address</label>
	<input type="email" class="form-control" id="exampleInputEmail1" aria-describedby="emailHelp">
	<div id="emailHelp" class="form-text">We'll never share your email with anyone else.</div>
</div>
<div class="mb-3">
	<label for="exampleInputPassword1" class="form-label">Password</label>
	<input type="password" class="form-control" id="exampleInputPassword1">
</div>

<!-- Button trigger modal -->
<button type="button" class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#exampleModal">
	로그인
</button>

<!-- Modal -->
<div class="modal fade" id="exampleModal" tabindex="-1" aria-labelledby="exampleModalLabel" aria-hidden="true">
<div class="modal-dialog">
	<div class="modal-content">
	<div class="modal-header">
		<h5 class="modal-title" id="exampleModalLabel">OTP 인증</h5>
		<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
	</div>
	<div class="modal-body">
		OTP 인증 키를 입력해주세요
		<input type="text" class="form-control mt-3" id="code" name="code">
	</div>
	<div class="modal-footer">
		<button type="button" class="btn btn-primary" onclick="checkOtp()">확인</button>
	</div>
	</div>
</div>
</div>
  

<!-- Button trigger modal -->
<button type="button" class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#exampleModal2">
	QR코드 만들기
</button>

<div class="modal fade" id="exampleModal2" tabindex="-1" aria-labelledby="exampleModalLabel2" aria-hidden="true">
<div class="modal-dialog">
	<div class="modal-content">
	<div class="modal-header">
		<h5 class="modal-title" id="exampleModalLabel2">QR 코드</h5>
		<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
	</div>
	<div class="modal-body">
		qr_code : <input type="text" class="form-control mt-3" id="qrCode" name="qr_code">
		url : <input type="text" class="form-control mt-3" id="siteName" name="site_name">
		user name : <input type="text" class="form-control mt-3" id="userName" name="user_name">
	</div>
	<div class="modal-footer">
		<button type="button" class="btn btn-primary" onclick="getQrPage()">URL 생성</button>
	</div>
	</div>
</div>
</div>

</body>
<script>
	    // --------------------- OTP ---------------------

	/** 
     *  @author : 이성재
     *  @why    : qr 만들기
     */

	let getQrPage = () =>{
		getQrPageAjax()
		.then((result) =>{
			window.open(result, 'pop01', 'top=10, left=10, width=500, height=600, status=no, menubar=no, toolbar=no, resizable=no');
		})
	}

	let getQrPageAjax = () => {
		return new Promise((resolve,reject) => {
		$.ajax({
		url: '/src/qr.php',
		type: 'GET',
		contentType: "application/x-www-form-urlencoded; charset=UTF-8",
		data: {
			qr_code : $("#qrCode").val(),
			site_name : $("#siteName").val(),
			user_name : $("#userName").val()
		},
		success: (result) => {resolve(result)}
		}); // ajax end	
		}); // promise end
	}

		

    /** 
     *  @author : 이성재
     *  @why    : otp 체크
     */


	let checkOtp = () => {

		if(validateOtp() !== true) return false;

		checkOtpAjax()
		.then((result) =>{
			if(result === "1") alert("OTP 패스워드가 맞습니다.");
			else alert("OTP 패스워드가 틀렸습니다.");
		})
	}

	/** 
	*  @author : 이성재
	*  @why    : otp 체크 ajax
	*/

	let checkOtpAjax = () => {
		return new Promise((resolve,reject) => {
		$.ajax({
		url: '/src/otp.php',
		type: 'POST',
		contentType: "application/x-www-form-urlencoded; charset=UTF-8",
		data: {
			code : $("#code").val()
		},
		success: (result) => {resolve(result)}
		}); // ajax end	
		}); // promise end
	}

	// otp_check

	/** 
	*  @author : 이성재
	*  @why    : otp Modal창 띄우기
	*/


	let showOtpModal = () =>{
		$(".container").css("background","rgba(0,0,0,0.6)")
		$("#adminLog").fadeOut();
		$(".modal").fadeIn();
	}

	/** 
	*  @author : 이성재
	*  @why    : otp 6자리 validate 체크
	*/

	let validateOtp = () => {

		if($("#code").val().length != 6) {
			alert("otp 6자리를 입력해주세요 (공백 X)");
			$("#code").focus();
			return false;
		}

		return true;

	}   

	/** 
	*  @author : 이성재
	*  @why    : OTP 전용 modal 창 
	*/
	$(function(){ 

		$("#code").keydown(function(key) {
		if (key.keyCode == 13) {
				checkOtp();
			}
		});

	});



</script>
</html>

index.php로 view 화면입니다.

 

<?php
require $_SERVER['DOCUMENT_ROOT']."/src/GoogleAuthenticator.php";

$qrCode = $_GET['qr_code'];     // 암호화 QR CODE값 기본적으로 아이디 + 패스워드를 합쳐서 Base32인코딩 및 추가적 암호화로 OTP 6자리 코드와 연결되는 중요한 부분
$provider = $_GET['site_name']; // 사이트 이름 Google OTP에서 구분할 사이트 명
$name = $_GET['user_name'];     // 사용자 이름 Google OTP에서 구분할 이름

$googleAuthenticator = new GoogleAuthenticator();

$secret = $googleAuthenticator->_base32Encode($qrCode);
$qrUrl = $googleAuthenticator->getQRCodeGoogleUrl($provider, $name, $secret);

echo $qrUrl;


?>

qr.php로 QR코드를 동적으로 만들기 위한 페이지입니다.

 

<?php
require $_SERVER['DOCUMENT_ROOT']."/src/GoogleAuthenticator.php";

$code = $_POST["code"];



$googleAuthenticator = new GoogleAuthenticator();
$secret = "5O4YJ25QQDWYFJHMT2C6XC4I5OF2I==="; // qr.php에서 만든 암호화키와 DB를 연결 안 해서 하드코딩으로 박아둠 ('비밀키입니다' 를 base32인코딩한 값)

$result = $googleAuthenticator->verifyCode($secret, $code);

echo $result;
?>

otp.php로 6자리 otp값이 맞는지 확인하는  부분입니다.

 

<?php 

// if (!defined('BASEPATH')) exit ('No direct script access allowed'); → 테스트를 위해서 막아둠

/**
 * PHP Class for handling Google Authenticator 2-factor authentication
 *
 * @author Michael Kliewe
 * @copyright 2012 Michael Kliewe
 * @license http://www.opensource.org/licenses/bsd-license.php BSD License
 * @link http://www.phpgangsta.de/
 */

class GoogleAuthenticator
{
    protected $_codeLength = 6;

    /**
     * Create new secret.
     * 16 characters, randomly chosen from the allowed base32 characters.
     *
     * @param int $secretLength
     * @return string
     */
    public function createSecret($secretLength = 16)
    {
        $validChars = $this->_getBase32LookupTable();
        unset($validChars[32]);

        $secret = '';
        for ($i = 0; $i < $secretLength; $i++) {
            $secret .= $validChars[array_rand($validChars)];
        }
        return $secret;
    }

    /**
     * Calculate the code, with given secret and point in time
     *
     * @param string $secret
     * @param int|null $timeSlice
     * @return string
     */
    public function getCode($secret, $timeSlice = null)
    {
        if ($timeSlice === null) {
            $timeSlice = floor(time() / 30);
        }

        $secretkey = $this->_base32Decode($secret);

        // Pack time into binary string
        $time = chr(0).chr(0).chr(0).chr(0).pack('N*', $timeSlice);
        // Hash it with users secret key
        $hm = hash_hmac('SHA1', $time, $secretkey, true);
        // Use last nipple of result as index/offset
        $offset = ord(substr($hm, -1)) & 0x0F;
        // grab 4 bytes of the result
        $hashpart = substr($hm, $offset, 4);

        // Unpak binary value
        $value = unpack('N', $hashpart);
        $value = $value[1];
        // Only 32 bits
        $value = $value & 0x7FFFFFFF;

        $modulo = pow(10, $this->_codeLength);
        return str_pad($value % $modulo, $this->_codeLength, '0', STR_PAD_LEFT);
    }

    /**
     * Get QR-Code URL for image, from google charts
     *
     * @param string $name
     * @param string $secret
     * @return string
     */
    public function getQRCodeGoogleUrl($provider, $name, $secret) {
        $urlencoded = urlencode('otpauth://totp/'.$name.'?secret='.$secret.'&issuer='.urlencode($provider).'');
        return 'https://chart.googleapis.com/chart?chs=200x200&chld=M|0&cht=qr&chl='.$urlencoded.'';
    }

    /**
     * Check if the code is correct. This will accept codes starting from $discrepancy*30sec ago to $discrepancy*30sec from now
     *
     * @param string $secret
     * @param string $code
     * @param int $discrepancy This is the allowed time drift in 30 second units (8 means 4 minutes before or after)
     * @return bool
     */
    public function verifyCode($secret, $code, $discrepancy = 1)
    {
        $currentTimeSlice = floor(time() / 30);

        for ($i = -$discrepancy; $i <= $discrepancy; $i++) {
            $calculatedCode = $this->getCode($secret, $currentTimeSlice + $i);
            if ($calculatedCode == $code ) {
                return true;
            }
        }

        return false;
    }

    /**
     * Set the code length, should be >=6
     *
     * @param int $length
     * @return PHPGangsta_GoogleAuthenticator
     */
    public function setCodeLength($length)
    {
        $this->_codeLength = $length;
        return $this;
    }

    /**
     * Helper class to decode base32
     *
     * @param $secret
     * @return bool|string
     */
    protected function _base32Decode($secret)
    {
        if (empty($secret)) return '';

        $base32chars = $this->_getBase32LookupTable();
        $base32charsFlipped = array_flip($base32chars);

        $paddingCharCount = substr_count($secret, $base32chars[32]);
        $allowedValues = array(6, 4, 3, 1, 0);
        if (!in_array($paddingCharCount, $allowedValues)) return false;
        for ($i = 0; $i < 4; $i++){
            if ($paddingCharCount == $allowedValues[$i] &&
                substr($secret, -($allowedValues[$i])) != str_repeat($base32chars[32], $allowedValues[$i])) return false;
        }
        $secret = str_replace('=','', $secret);
        $secret = str_split($secret);
        $binaryString = "";
        for ($i = 0; $i < count($secret); $i = $i+8) {
            $x = "";
            if (!in_array($secret[$i], $base32chars)) return false;
            for ($j = 0; $j < 8; $j++) {
                $x .= str_pad(base_convert(@$base32charsFlipped[@$secret[$i + $j]], 10, 2), 5, '0', STR_PAD_LEFT);
            }
            $eightBits = str_split($x, 8);
            for ($z = 0; $z < count($eightBits); $z++) {
                $binaryString .= ( ($y = chr(base_convert($eightBits[$z], 2, 10))) || ord($y) == 48 ) ? $y:"";
            }
        }
        return $binaryString;
    }

    /**
     * Helper class to encode base32
     *
     * @param string $secret
     * @param bool $padding
     * @return string
     */
    public function _base32Encode($secret, $padding = true)
    {
        if (empty($secret)) return '';

        $base32chars = $this->_getBase32LookupTable();

        $secret = str_split($secret);
        $binaryString = "";
        for ($i = 0; $i < count($secret); $i++) {
            $binaryString .= str_pad(base_convert(ord($secret[$i]), 10, 2), 8, '0', STR_PAD_LEFT);
        }
        $fiveBitBinaryArray = str_split($binaryString, 5);
        $base32 = "";
        $i = 0;
        while ($i < count($fiveBitBinaryArray)) {
            $base32 .= $base32chars[base_convert(str_pad($fiveBitBinaryArray[$i], 5, '0'), 2, 10)];
            $i++;
        }
        if ($padding && ($x = strlen($binaryString) % 40) != 0) {
            if ($x == 8) $base32 .= str_repeat($base32chars[32], 6);
            elseif ($x == 16) $base32 .= str_repeat($base32chars[32], 4);
            elseif ($x == 24) $base32 .= str_repeat($base32chars[32], 3);
            elseif ($x == 32) $base32 .= $base32chars[32];
        }
        return $base32;
    }

    /**
     * Get array with all 32 characters for decoding from/encoding to base32
     *
     * @return array
     */
    protected function _getBase32LookupTable()
    {
        return array(
            'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', //  7
            'I', 'J', 'K', 'L', 'M', 'N', 'O', 'P', // 15
            'Q', 'R', 'S', 'T', 'U', 'V', 'W', 'X', // 23
            'Y', 'Z', '2', '3', '4', '5', '6', '7', // 31
            '='  // padding char
        );
    }
}

GoogleAuthenticator.php로 OTP 인증, Base32 인코딩, 디코딩 기능을 지원해주는 라이브러리입니다. 

 

프로젝트 구조입니다.

 

해당 정보로 만들어진 QR 링크입니다.

 

https://chart.googleapis.com/chart?chs=200x200&chld=M|0&cht=qr&chl=otpauth%3A%2F%2Ftotp%2F%EC%9B%94%EC%9B%94%EC%9D%B4%3Fsecret%3D5O4YJ25QQDWYFJHMT2C6XC4I5OF2I%3D%3D%3D%26issuer%3Dwww.naver.com

 

5O4YJ25QQDWYFJHMT2C6XC4I5OF2I%3D%3D%3D에 해당하는 부분이 "비밀키입니다"를 Base32 인코딩한 값입니다.

(암호화 더 필요한 경우 다양하게 알아서 해주세요)

 

 

 

 

 

--------------------------------------------------------------------------------------------------

  www.naver.com (월월이)

  943 316 (QR코드 기반으로 만들어지는 OTP 6자리)

--------------------------------------------------------------------------------------------------

 

해당 QR을 Google Authenticator 앱을 통해 등록해줍니다.

그러면 앱에 등록되면 위 같은 형식으로 나오게됩니다. (보안 정책상 캡처가 불가능 해서 사진으로 못 보여드리네요)

 

성공시 "1"이라는 키가 들어오고 그걸로 OTP가 맞는지 Java Script에서 확인했습니다.

반응형