수업소개
이번 수업에서는 Session 시간에 만든 로그인 기능의 보안성을 강화하는 방법을 알아본다. 또한 회원가입 기능을 구현해서 웹서비스를 다중 사용자 시스템으로 만드는 방법도 살펴볼 것이다. 그 과정에서 공개된 외부 라이브러리를 가져와서 프로젝트에 적용하는 방법도 배워보자.
이번 시간에 알아볼 password_compat 라이브러리는 PHP 5.3.7부터 지원한다. 또 네이티브 API인 password_hash는 php 5.5부터 지원한다.
회원가입
회원가입을 통해서 인증된 복수의 사용자가 서비스를 사용하도록 해보자. 이를 위해서는 회원의 정보를 어딘가에 저장했다가 로그인이 시도되면 데이터베이스에 저장된 정보와 비교한다. 비교결과 인증된 사용자임이 확인되면 세션을 발급해서 로그인된 상태를 유지할 수 있도록 한다. 세션과 관련된 부분은 이미 세션 수업을 통해서 배웠다.
회원의 로그인 정보를 저장하기 위해서는 데이터베이스 테이블을 설계해야 한다. user라는 이름의 테이블을 만들고 아래의 SQL문을 실행해서 회원정보를 저장할 테이블을 만들자. password는 암호화된 데이터가 저장되기 때문에 충분히 큰 길이를 지정했고, email은 일반적으로 50글자가 넘지 않기 때문에 50 글자 제한을 두었다. email은 모든 회원이 중복되지 않는 유일무일한 식별자가를 가져야 하기 때문에 중복을 허용하지 않는 UNIQUE 타입의 인덱스로 지정했다.
CREATE TABLE `user` ( `id` int(11) NOT NULL AUTO_INCREMENT, `email` varchar(50) NOT NULL, `password` varchar(255) NOT NULL, `created` datetime NOT NULL, PRIMARY KEY (`id`), UNIQUE KEY `email_idx` (`email`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8;
회원가입 버튼 추가
/application/views/head.php
차이점
코드
<ul class="nav pull-right"> <?php if($this->session->userdata('is_login')){ ?> <li><a href="/index.php/auth/logout">로그아웃</a></li> <?php } else { ?> <li><a href="/index.php/auth/login">로그인</a></li> <li><a href="/index.php/auth/register">회원가입</a></li> <?php } ?> </ul>
회원정보 입력 뷰 생성
사용자로부터 회원정보를 입력받아서 서버로 전달할 양식을 만들어보자.
/application/views/register.php
<div> <div class="span4"></div> <div class="span4"> <?php echo validation_errors(); ?> <form class="form-horizontal" action="/index.php/auth/register" method="post"> <div class="control-group"> <label class="control-label" for="inputEmail">이메일</label> <div class="controls"> <input type="text" id="email" name="email" value="<?php echo set_value('email'); ?>" placeholder="이메일"> </div> </div> <div class="control-group"> <label class="control-label" for="nickname">닉네임</label> <div class="controls"> <input type="text" id="nickname" name="nickname" value="<?php echo set_value('nickname'); ?>" placeholder="닉네임"> </div> </div> <div class="control-group"> <label class="control-label" for="password">비밀번호</label> <div class="controls"> <input type="password" id="password" name="password" value="<?php echo set_value('password'); ?>" placeholder="비밀번호"> </div> </div> <div class="control-group"> <label class="control-label" for="re_password">비밀번호 확인</label> <div class="controls"> <input type="password" id="re_password" name="re_password" value="<?php echo set_value('re_password'); ?>" placeholder="비밀번호 확인"> </div> </div> <div class="control-group"> <label class="control-label"></label> <div class="controls"> <input type="submit" class="btn btn-primary" value="회원가입" /> </div> </div> </form> </div> <div class="span4"></div> </div>
회원정보 모델 생성
회원정보를 저장하는 user 테이블을 제어할 모델을 만들어보자. 모델은 회원정보를 읽고, 쓰고, 수정하는 일반적인 기능이 담겨있다.
/application/models/usre_model.php
코드
<?php class User_model extends CI_Model { function __construct() { parent::__construct(); } function gets() { return $this->db->query("SELECT * FROM user")->result(); } function get($option) { $result = $this->db->get_where('user', array('email'=>$option['email']))->row(); var_dump($this->db->last_query()); return $result; } function add($option) { $this->db->set('email', $option['email']); $this->db->set('password', $option['password']); $this->db->set('created', 'NOW()', false); $this->db->insert('user'); $result = $this->db->insert_id(); return $result; } }
회원정보 컨트롤러 생성
회원정보를 입력하기 위한 뷰와 모델을 만들었기 때문에 이것들을 결합시켜줄 컨트롤러를 만들어보자. 컨트롤러 auth.php에 회원가입 페이지를 출력하는 메소드를 만들자. 아래의 URL로 접근 했을 때 페이지를 출력하고, 데이터를 입력하는 역할을 하는 페이지를 보여줄 것이다.
http://ooo2.org/index.php/auth/register
만약 password_hash가 존재하지 않는 함수라는 오류가 발생하면 본 토픽의 말미에 있는 외부 라이브러리 사용이라는 내용을 참조하자.
/application/controllers/auth.php
차이점
코드
function register(){ $this->_head(); $this->load->library('form_validation'); $this->form_validation->set_rules('email', '이메일 주소', 'required|valid_email|is_unique[user.email]'); $this->form_validation->set_rules('nickname', '닉네임', 'required|min_length[5]|max_length[20]'); $this->form_validation->set_rules('password', '비밀번호', 'required|min_length[6]|max_length[30]|matches[re_password]'); $this->form_validation->set_rules('re_password', '비밀번호 확인', 'required'); if($this->form_validation->run() === false){ $this->load->view('register'); } else { if(!function_exists('password_hash')){ $this->load->helper('password'); } $hash = password_hash($this->input->post('password'), PASSWORD_BCRYPT); $this->load->model('user_model'); $this->user_model->add(array( 'email'=>$this->input->post('email'), 'password'=>$hash, 'nickname'=>$this->input->post('nickname') )); $this->session->set_flashdata('message', '회원가입에 성공했습니다.'); $this->load->helper('url'); redirect('/'); } $this->_footer(); }
인증방법
사용자의 비밀번호는 시스템에서 가장 귀하고 위험한 정보다. 일반적인 사용자들은 모든 서비스에서 동일한 비밀번호를 사용하기 때문에 사용자의 비밀번호가 유출되면 그 사용자가 사용하는 모든 서비스까지 연쇄적으로 위험에 처하게 된다. 비밀번호가 유출될 수 있는 방법은 많지만 가장 대표적인 것이 회원정보가 담겨있는 데이터베이스에 침입자가 접속하는 경우다. 이런 경우 사용자의 비밀번호가 암호화되어 있다면 침입자는 의미없는 데이터만을 획득할 수 있기 때문에 이 정보를 가지고 제2의 공격을 시도하기 어렵다. 이에 대한 자세한 내용은 NHN의 개발자 블로그인 Hello world, 안전한 패스워드에 대한 내용을 꼼꼼하게 정독하자.
단방향 hash
단방향 해쉬는 쉽게 이야기해서 비밀번호를 풀 수 없는 방법으로 암호화하는 것이다. 이를테면 사용자의 비밀번호가 111111 이라고 할 때 이것을 단방향 해쉬하면 아래와 같은 정보로 변환된다. 이것을 저장하고 사용자의 비밀번호 111111은 기록하지 않는다.
$10$k9lKqq1mxgQTI8fR1tf/GexMDd6wcU52gx931t4/5J/qZeah/6acm
사용자가 로그인을 시도할 때는 사용자가 입력한 비밀번호를 해쉬한 결과값과 데이터베이스에 저장된 결과값을 비교해서 일치하면 인증된 사용자임을 식별할 수 있다.
PHP의 단방향 해쉬
PHP는 5.5 버전부터 password_hash라는 비밀번호용 해쉬를 쉽게 만들 수 있는 API를 제공한다. 사용하고 있는 PHP의 버전이 5.5 하위 버전이라면 github의 password_compat를 사용한다. 이 함수를 이용해서 해쉬를 생성할 때는 아래와 같은 구문을 사용한다. 이것은 $password의 값을 BCRYPT 방식으로 암호화한다. BCRYPT 방식은 현재로서는 충분히 안전한 암호화 방식으로 알려졌고, 현재까지 PHP에서 제공하는 가장 강력한 암호화 방식이기도 하다. 아래 구문을 통해서 획득한 $hash의 값을 데이터베이스에 저장한다.
$hash = password_hash($password, PASSWORD_BCRYPT);
사용자가 입력한 값과 데이터베이스에 저장된 값이 일치하는지를 확인하기 위해서는 password_verify를 사용한다. 이 함수의 리턴값이 true라면 비밀번호가 일치하는 것이다.
if (password_verify($password, $hash)) { /* Valid */ } else { /* Invalid */ }
로그인
로그인 로직을 변경해보자. Session 수업에서는 config.php 파일에 기록한 아이디와 비밀번호를 이용해서 인증을 했는데, 이제는 데이터베이스의 정보를 사용해서 인증을 하도록 변경해보자. 또한 저장된 사용자 정보가 BCRYPT 방식이기 때문에 인증도 BCRYPT 방식으로 진행해야 한다.
Application/controllers/auth.php
차이점
코드
function authentication(){ $this->load->model('user_model'); $user = $this->user_model->getByEmail(array('email'=>$this->input->post('email'))); if(!function_exists('password_hash')){ $this->load->helper('password'); } if( $this->input->post('email') == $user->email && password_verify($this->input->post('password'), $user->password) ) { $this->session->set_userdata('is_login', true); $this->load->helper('url'); redirect("/"); } else { echo "불일치"; $this->session->set_flashdata('message', '로그인에 실패 했습니다.'); $this->load->helper('url'); redirect('/auth/login'); } }
로그인 페이지에서 서버로 전송하는 id를 email로 변경하자.
/application/views/login.php
차이점
코드
<div class="control-group"> <label class="control-label" for="inputEmail">아이디</label> <div class="controls"> <input type="text" id="email" name="email" placeholder="이메일"> </div> </div> <div class="control-group"> <label class="control-label" for="inputPassword">비밀번호</label> <div class="controls"> <input type="password" id="password" name="password" placeholder="비밀번호"> </div> </div>
외부 라이브러리 사용
외부의 라이브러리를 사용할 때는 그것이 클래스 기반인지, 함수 기반인지를 먼저 살펴봐야 한다. 함수 기반이라면 helper의 체계로 사용하고, 클래스 기반이라면 Library로 사용하면 된다.
예제에서 password_hash라는 PHP의 API를 사용했는데, 이 API는 PHP 5.5부터 지원된다. 해서 이 API와 호환되는 라이브러리를 찾아봤더니 password_compat라는 것이 있었다. 이 함수는 후에 사용 중인 PHP의 버전이 password_hash를 지원하면 자동으로 해당 라이브러리를 사용하도록 구현되어 있다.
아래의 절차로 진행한다.
- 파일을 다운로드 한다.
https://github.com/ircmaxell/password_compat - /lib/password.php 파일을 다운로드 받아서 /application/helpers/password_helper.php 로 저장한다.
- password_* 명령을 사용하기 전에 아래의 구문을 이용해서 password_helper를 로드한다.
$this->load->helper('password');
/application/helpers/password_helper.php
<?php /** * A Compatibility library with PHP 5.5's simplified password hashing API. * * @author Anthony Ferrara <ircmaxell@php.net> * @license http://www.opensource.org/licenses/mit-license.html MIT License * @copyright 2012 The Authors */ if (!defined('PASSWORD_BCRYPT')) { define('PASSWORD_BCRYPT', 1); define('PASSWORD_DEFAULT', PASSWORD_BCRYPT); /** * Hash the password using the specified algorithm * * @param string $password The password to hash * @param int $algo The algorithm to use (Defined by PASSWORD_* constants) * @param array $options The options for the algorithm to use * * @return string|false The hashed password, or false on error. */ function password_hash($password, $algo, array $options = array()) { if (!function_exists('crypt')) { trigger_error("Crypt must be loaded for password_hash to function", E_USER_WARNING); return null; } if (!is_string($password)) { trigger_error("password_hash(): Password must be a string", E_USER_WARNING); return null; } if (!is_int($algo)) { trigger_error("password_hash() expects parameter 2 to be long, " . gettype($algo) . " given", E_USER_WARNING); return null; } switch ($algo) { case PASSWORD_BCRYPT: // Note that this is a C constant, but not exposed to PHP, so we don't define it here. $cost = 10; if (isset($options['cost'])) { $cost = $options['cost']; if ($cost < 4 || $cost > 31) { trigger_error(sprintf("password_hash(): Invalid bcrypt cost parameter specified: %d", $cost), E_USER_WARNING); return null; } } $required_salt_len = 22; $hash_format = sprintf("$2y$d$", $cost); break; default: trigger_error(sprintf("password_hash(): Unknown password hashing algorithm: %s", $algo), E_USER_WARNING); return null; } if (isset($options['salt'])) { switch (gettype($options['salt'])) { case 'NULL': case 'boolean': case 'integer': case 'double': case 'string': $salt = (string) $options['salt']; break; case 'object': if (method_exists($options['salt'], '__tostring')) { $salt = (string) $options['salt']; break; } case 'array': case 'resource': default: trigger_error('password_hash(): Non-string salt parameter supplied', E_USER_WARNING); return null; } if (strlen($salt) < $required_salt_len) { trigger_error(sprintf("password_hash(): Provided salt is too short: %d expecting %d", strlen($salt), $required_salt_len), E_USER_WARNING); return null; } elseif (0 == preg_match('#^[a-zA-Z0-9./]+$#D', $salt)) { $salt = str_replace('+', '.', base64_encode($salt)); } } else { $buffer = ''; $raw_length = (int) ($required_salt_len * 3 / 4 + 1); $buffer_valid = false; if (function_exists('mcrypt_create_iv') && !defined('PHALANGER')) { $buffer = mcrypt_create_iv($raw_length, MCRYPT_DEV_URANDOM); if ($buffer) { $buffer_valid = true; } } if (!$buffer_valid && function_exists('openssl_random_pseudo_bytes')) { $buffer = openssl_random_pseudo_bytes($raw_length); if ($buffer) { $buffer_valid = true; } } if (!$buffer_valid && is_readable('/dev/urandom')) { $f = fopen('/dev/urandom', 'r'); $read = strlen($buffer); while ($read < $raw_length) { $buffer .= fread($f, $raw_length - $read); $read = strlen($buffer); } fclose($f); if ($read >= $raw_length) { $buffer_valid = true; } } if (!$buffer_valid || strlen($buffer) < $raw_length) { $bl = strlen($buffer); for ($i = 0; $i < $raw_length; $i++) { if ($i < $bl) { $buffer[$i] = $buffer[$i] ^ chr(mt_rand(0, 255)); } else { $buffer .= chr(mt_rand(0, 255)); } } } $salt = str_replace('+', '.', base64_encode($buffer)); } $salt = substr($salt, 0, $required_salt_len); $hash = $hash_format . $salt; $ret = crypt($password, $hash); if (!is_string($ret) || strlen($ret) <= 13) { return false; } return $ret; } /** * Get information about the password hash. Returns an array of the information * that was used to generate the password hash. * * array( * 'algo' => 1, * 'algoName' => 'bcrypt', * 'options' => array( * 'cost' => 10, * ), * ) * * @param string $hash The password hash to extract info from * * @return array The array of information about the hash. */ function password_get_info($hash) { $return = array( 'algo' => 0, 'algoName' => 'unknown', 'options' => array(), ); if (substr($hash, 0, 4) == '$2y$' && strlen($hash) == 60) { $return['algo'] = PASSWORD_BCRYPT; $return['algoName'] = 'bcrypt'; list($cost) = sscanf($hash, "$2y$%d$"); $return['options']['cost'] = $cost; } return $return; } /** * Determine if the password hash needs to be rehashed according to the options provided * * If the answer is true, after validating the password using password_verify, rehash it. * * @param string $hash The hash to test * @param int $algo The algorithm used for new password hashes * @param array $options The options array passed to password_hash * * @return boolean True if the password needs to be rehashed. */ function password_needs_rehash($hash, $algo, array $options = array()) { $info = password_get_info($hash); if ($info['algo'] != $algo) { return true; } switch ($algo) { case PASSWORD_BCRYPT: $cost = isset($options['cost']) ? $options['cost'] : 10; if ($cost != $info['options']['cost']) { return true; } break; } return false; } /** * Verify a password against a hash using a timing attack resistant approach * * @param string $password The password to verify * @param string $hash The hash to verify against * * @return boolean If the password matches the hash */ function password_verify($password, $hash) { if (!function_exists('crypt')) { trigger_error("Crypt must be loaded for password_verify to function", E_USER_WARNING); return false; } $ret = crypt($password, $hash); if (!is_string($ret) || strlen($ret) != strlen($hash) || strlen($ret) <= 13) { return false; } $status = 0; for ($i = 0; $i < strlen($ret); $i++) { $status |= (ord($ret[$i]) ^ ord($hash[$i])); } return $status === 0; } }
태그
태그명 : register
태그주소 : https://github.com/egoing/codeigniter_codeingeverbody/tree/register