📝OTP
one time password로 무작위 생성된 일회용 패스워드로 인증하는 방식을 의미합니다.
ℹ️ OTP의 동작과정
- 암호화 키를 만든다. (아이디 + 비밀번호를 합쳐서 암호화 하든가 등...)
- DB에 암호화키를 저장하고 암호화키 기반으로 Google에서 인증 QR을 만든다.
- 인증 QR을 통해 앱에 등록한다.
- 앱에서 인증 QR기반으로 랜덤 6자리 숫자키가 만들어진다.
- 사용자가 입력한 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에서 확인했습니다.