iT邦幫忙

2018 iT 邦幫忙鐵人賽
DAY 17
2
Modern Web

寫給朋友的 PHP 從 0 到 100 實戰教程系列 第 17

Day 17. PHP教學: 實作註冊流程

本範例參考改寫此專案,大部分轉物件並套入自訂框架

https://github.com/daveismyname/loginregister

先來建立一個註冊畫面吧
補上一個使用 bootstrap 樣式套件的頁首

view/header/default.php

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="utf-8">
    <title><?php if(isset($title)){ echo $title; }?></title>
    <link href="//netdna.bootstrapcdn.com/bootstrap/3.1.0/css/bootstrap.min.css" rel="stylesheet">
</head>
<body>

加上這個樣式套件,在表單上加上一些 style class 就會漂亮起來
https://ithelp.ithome.com.tw/upload/images/20180103/20107394ZpXaui2TWM.png

view/body/register.php

<div class="container">
	<div class="row">
	    <div class="col-xs-12 col-sm-8 col-md-6 col-sm-offset-2 col-md-offset-3">
			<form role="form" method="post" action="" autocomplete="off">
				<h2>Please Sign Up</h2>
				<p>Already a member? <a href='login.php'>Login</a></p>
				<hr>
				<?php
				//check for any errors
				if(isset($error)){
					foreach($error as $error){
						echo '<p class="bg-danger">'.$error.'</p>';
					}
				}

				//if action is joined show sucess
				if(isset($_GET['action']) && $_GET['action'] == 'joined'){
					echo "<h2 class='bg-success'>Registration successful, please check your email to activate your account.</h2>";
				}
				?>

				<div class="form-group">
					<input type="text" name="username" id="username" class="form-control input-lg" placeholder="User Name" value="<?php if(isset($error)){ echo htmlspecialchars($_POST['username'], ENT_QUOTES); } ?>" tabindex="1">
				</div>
				<div class="form-group">
					<input type="email" name="email" id="email" class="form-control input-lg" placeholder="Email Address" value="<?php if(isset($error)){ echo htmlspecialchars($_POST['email'], ENT_QUOTES); } ?>" tabindex="2">
				</div>
				<div class="row">
					<div class="col-xs-6 col-sm-6 col-md-6">
						<div class="form-group">
							<input type="password" name="password" id="password" class="form-control input-lg" placeholder="Password" tabindex="3">
						</div>
					</div>
					<div class="col-xs-6 col-sm-6 col-md-6">
						<div class="form-group">
							<input type="password" name="passwordConfirm" id="passwordConfirm" class="form-control input-lg" placeholder="Confirm Password" tabindex="4">
						</div>
					</div>
				</div>

				<div class="row">
					<div class="col-xs-6 col-md-6"><input type="submit" name="submit" value="Register" class="btn btn-primary btn-block btn-lg" tabindex="5"></div>
				</div>
			</form>
		</div>
	</div>

</div>

在來就是路由上新增一個 case "register"
要做的事情有幾樣

  • 顯示畫面
include('view/header/default.php'); // 載入共用的頁首
include('view/body/register.php');  // 載入註冊用的表單
include('view/footer/default.php'); // 載入共用的頁尾
  • 接收表單傳來的資料
$gump = new GUMP();
$_POST = $gump->sanitize($_POST); 
  • 驗證資料合法性
        $validation_rules_array = array(
          'username'    => 'required|alpha_numeric|max_len,20|min_len,8',
          'email'       => 'required|valid_email',
          'password'    => 'required|max_len,20|min_len,8',
          'passwordConfirm' => 'required'
        );
        $gump->validation_rules($validation_rules_array);

        $filter_rules_array = array(
          'username' => 'trim|sanitize_string',
          'email'    => 'trim|sanitize_email',
          'password' => 'trim',
          'passwordConfirm' => 'trim'
        );
        $gump->filter_rules($filter_rules_array);

        $validated_data = $gump->run($_POST);

        if($validated_data === false) {
          $error = $gump->get_readable_errors(false);
        }
  • 檢查帳號與信箱是否存在資料庫了
  • 檢查輸入兩次的密碼是否相符

新增 validators/UserVeridator.php

<?php
/**
 * 耦合使用 Database 物件進行資料庫驗證 username 與 email 是否已存在於資料庫
 */
class UserVeridator {

    private $error;

    /**
     * 可取出錯誤訊息字串陣列
     */
    public function getErrorArray(){
        return $this->error;
    }

    /**
     * 驗證二次密碼輸入是否相符
     */
    public function isPasswordMatch($password, $passwrodConfirm){
		if ($password != $passwrodConfirm){
            $this->error[] = 'Passwords do not match.';
            return false;
        }
		return true;
    }

    /**
     * 驗證帳號是否已存在於資料庫中
     */
    public function isUsernameDuplicate($username){
        $result = Database::get()->execute('SELECT username FROM members WHERE username = :username', array(':username' => $username));
        if(isset($result[0]['username']) and !empty($result[0]['username'])){
          $this->error[] = 'Username provided is already in use.';
          return false;
        }
		return true;
    }

    /**
     * 驗證信箱是否已存在於資料庫中
     */
    public function isEmailDuplicate($email){
        $result = Database::get()->execute('SELECT email FROM members WHERE email = :email', array(':email' => $email));
        if(isset($result[0]['email']) AND !empty($result[0]['email'])){
            $this->error[] = 'Email provided is already in use.';
            return false;
        }
		return true;
    }
    
    
}

記得要把 validators 資料夾名稱 加到 composer 的 autoload class map 裡面

{
    "name": "jarvis/game",
    "authors": [
        {
            "name": "Jarvis",
            "email": "endless640c@gmail.com"
        }
    ],
    "require": {
      "monolog/monolog": "^1.23",
        "wixel/gump": "^1.5",
        "fightbulc/moment": "^1.26",
        "phpmailer/phpmailer": "^6.0"
    },
    "autoload": {
      "classmap": [
          "validators",
          "libraries",
          "config"
      ]
    }
}

再執行 composer dump 就可以使用囉

route.php

// validation successful
foreach($validation_rules_array as $key => $val) {
  ${$key} = $_POST[$key];
}
$userVeridator = new UserVeridator();
$userVeridator->isPasswordMatch($password, $passwordConfirm);
$userVeridator->isUsernameDuplicate($username);
$userVeridator->isEmailDuplicate($email);
$error = $userVeridator->getErrorArray();

新增 libraries/Password.php 來處理密碼加密

<?php
if (!defined('PASSWORD_BCRYPT')) {
        define('PASSWORD_BCRYPT', 1);
        define('PASSWORD_DEFAULT', PASSWORD_BCRYPT);
}

Class Password {

    public function __construct() {}
    /**
     * 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.
     */
    public 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;
                    }
                }
                // The length of salt to generate
                $raw_salt_len = 16;
                // The length required in the final serialization
                $required_salt_len = 22;
                $hash_format = sprintf("$2y$%02d$", $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 {
            $salt = str_replace('+', '.', base64_encode($this->generate_entropy($required_salt_len)));
        }
        $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;
    }


    /**
     * Generates Entropy using the safest available method, falling back to less preferred methods depending on support
     *
     * @param int $bytes
     *
     * @return string Returns raw bytes
     */
    function generate_entropy($bytes){
        $buffer = '';
        $buffer_valid = false;
        if (function_exists('mcrypt_create_iv') && !defined('PHALANGER')) {
            $buffer = mcrypt_create_iv($bytes, MCRYPT_DEV_URANDOM);
            if ($buffer) {
                $buffer_valid = true;
            }
        }
        if (!$buffer_valid && function_exists('openssl_random_pseudo_bytes')) {
            $buffer = openssl_random_pseudo_bytes($bytes);
            if ($buffer) {
                $buffer_valid = true;
            }
        }
        if (!$buffer_valid && is_readable('/dev/urandom')) {
            $f = fopen('/dev/urandom', 'r');
            $read = strlen($buffer);
            while ($read < $bytes) {
                $buffer .= fread($f, $bytes - $read);
                $read = strlen($buffer);
            }
            fclose($f);
            if ($read >= $bytes) {
                $buffer_valid = true;
            }
        }
        if (!$buffer_valid || strlen($buffer) < $bytes) {
            $bl = strlen($buffer);
            for ($i = 0; $i < $bytes; $i++) {
                if ($i < $bl) {
                    $buffer[$i] = $buffer[$i] ^ chr(mt_rand(0, 255));
                } else {
                    $buffer .= chr(mt_rand(0, 255));
                }
            }
        }
        return $buffer;
    }

    /**
     * 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
     */
    public 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;
    }
}

route.php 整個組合起來:

switch($route->getParameter(1)){
    case "register";
      if(isset($_POST['submit'])) 
      {
        $gump = new GUMP();

        $_POST = $gump->sanitize($_POST); 

        $validation_rules_array = array(
          'username'    => 'required|alpha_numeric|max_len,20|min_len,8',
          'email'       => 'required|valid_email',
          'password'    => 'required|max_len,20|min_len,8',
          'passwordConfirm' => 'required'
        );
        $gump->validation_rules($validation_rules_array);

        $filter_rules_array = array(
          'username' => 'trim|sanitize_string',
          'email'    => 'trim|sanitize_email',
          'password' => 'trim',
          'passwordConfirm' => 'trim'
        );
        $gump->filter_rules($filter_rules_array);

        $validated_data = $gump->run($_POST);

        if($validated_data === false) {
          $error = $gump->get_readable_errors(false);
        } else {
          // validation successful
          foreach($validation_rules_array as $key => $val) {
            ${$key} = $_POST[$key];
          }
          $userVeridator = new UserVeridator();
          $userVeridator->isPasswordMatch($password, $passwordConfirm);
          $userVeridator->isUsernameDuplicate($username);
          $userVeridator->isEmailDuplicate($email);
          $error = $userVeridator->getErrorArray();
        } 
        //if no errors have been created carry on
        if(count($error) == 0)
        {
          //hash the password
          $passwordObject = new Password();
          $hashedpassword = $passwordObject->password_hash($password, PASSWORD_BCRYPT);
      
          //create the random activasion code
          $activasion = md5(uniqid(rand(),true));
      
          try {

            // 新增到資料庫
            $data_array = array(
              'username' => $username,
              'password' => $hashedpassword,
              'email' => $email,
              'active' => $activasion
            );
            Database::get()->insert("members", $data_array);

            //redirect to index page
            header('Location: '.Config::BASE_URL.'register');
            
          //else catch the exception and show the error.
          } catch(PDOException $e) {
              $error[] = $e->getMessage();
          }
        }
      }
      include('view/header/default.php'); // 載入共用的頁首
      include('view/body/register.php');  // 載入註冊用的表單
      include('view/footer/default.php'); // 載入共用的頁尾
    break;
 //...以下重複略...
 }

最終就達成我們要的樣子,列出錯誤的資訊
https://ithelp.ithome.com.tw/upload/images/20180103/20107394glJTSzzKxD.png


上一篇
Day 16. PHP教程: 將 MySQLi 升級為 PDO (PHP Data Object)
下一篇
Day 18. PHP教程: 實作登入與登出機制
系列文
寫給朋友的 PHP 從 0 到 100 實戰教程30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

1 則留言

0
a600masool
iT邦新手 5 級 ‧ 2020-03-25 11:25:22

PASSWORD_BCRYPT 預設鹽值已更新為 '2y'
password_hash 執行時判定 $algo 為數字會報錯,正確應為字串

if (!is_int($algo)) { // 會報錯
if (!is_string($algo)) { // 要改成這樣

我要留言

立即登入留言