3
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

CodeIgniterでメールによるパスワード再設定機能を実装する

Posted at

現時点では自分用の備忘録です。
ちょっと定時過ぎてるしさっさと帰りたいんでソースコード丸々掲載します。

現時点では、親クラスのメソッドに依存している記述が多く、
他の方が見てもなんやこれ状態だと思いますので、
そのうち、ちゃんとまとめたいと思ってます(思ってるだけ)

要件

  • メアドとパスワードで管理画面にログインする(ログイン機能の記述は割愛します)
  • パスワードを忘れた場合、専用フォームにメアドを入力することで、再設定リンクつきのメールを送信
  • 再設定リンクの有効期限はメール送信から1時間以内
  • 再設定ページで、新しいパスワードを入力することで、パスワードを更新、管理画面にログインする

DB


CREATE TABLE IF NOT EXISTS `users` (
  `id` int(11) NOT NULL AUTO_INCREMENT,
  `email` varchar(255) NOT NULL,
  `password` varchar(255) NOT NULL,
  `label` varchar(255) NOT NULL,
  `created_at` timestamp NOT NULL DEFAULT '0000-00-00 00:00:00',
  `updated_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
  PRIMARY KEY (id),
  UNIQUE (email)
) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8;

CREATE TABLE IF NOT EXISTS `password_resets` (
  `email` varchar(255) NOT NULL,
  `token` varchar(255) NOT NULL,
  `created_at` timestamp NOT NULL DEFAULT '0000-00-00 00:00:00',
  KEY (`email`, `token`)
) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8;

Config

application/configs/auth.php

<?php
defined('BASEPATH') OR exit('No direct script access allowed');

// @see https://qiita.com/Go-Noji/items/6a8e09e66b3f4857266e

$config['salt'] = 'h6F4htejW69moG6gndjplC12'; // なんでもいい
$config['auth_time'] = 3600; // 3600秒=1時間

Controller

application/controllers/Auth.php

<?php
defined('BASEPATH') OR exit('No direct script access allowed');
/**
 * @see application/classes/Admin_Controller.php
 */
class Auth extends Admin_Controller
{
    private $columnNameUserName = 'email';
    private $columnNameUserPassword = 'password';

    public function __construct()
    {
        parent::__construct();
        $this->loadModel('users');
        $this->loadModel('password_resets');
        $this->config->load('auth');
    }

    /**
     * パスワードを忘れた方はこちらページ
     */
    public function password_send()
    {
        $this->view_data = $this->viewVariablesBase();

        if($this->isVerified()){
            $this->setInfoMessage("既にログイン済みです");
            $this->redirectToDashBoard();
        }

        if($this->isGetMethod()){
            return $this->displayView($this->view_data);
        }
        else if($this->isPostMethod()){
            return $this->passwordSendAction();
        }
    }

    /**
     * パスワード再設定メールを送りましたページ
     */
    public function password_sent()
    {
        $this->view_data = $this->viewVariablesBase();

        if($this->isVerified()){
            $this->setInfoMessage("既にログイン済みです");
            $this->redirectToDashBoard();
        }

        return $this->displayView($this->view_data);
    }

    /**
     * パスワード再設定ページ
     */
    public function password_reset()
    {
        $this->view_data = $this->viewVariablesBase();

        if($this->isVerified()){
            $this->setInfoMessage("既にログイン済みです");
            $this->redirectToDashBoard();
        }

        if($this->isGetMethod()){
            $currentToken = $this->input->get('token');

            if($this->isValidEmailToken($currentToken) === FALSE){
                $this->setDangerMessage("アクセストークンが正しくないか失効しています。\nお手数ですが、操作をやり直してください");
                return $this->redirectToLoginPage();
            }

            $this->view_data['token'] = $this->token;

            return $this->displayView($this->view_data);
        }
        else if($this->isPostMethod()){
            $currentToken = $this->input->post('token');

            if($this->isValidEmailToken($currentToken) === FALSE){
                $this->setDangerMessage("アクセストークンが正しくないか失効しています。\nお手数ですが、操作をやり直してください");
                return $this->redirectToLoginPage();
            }

            // 有効期限切れのアクセストークンをついでに削除
            $this->deleteExpiredAccessTokens();

            return $this->passwordResetAction();
        }
    }

    private function passwordSendAction()
    {
        // 基本的な入力バリデーションのチェック(DB接続なし) @see application/rules/admin/auth.php
        if(! $this->form_validation->run('password_send')){
            return $this->redirectBackWithInput();
        }

        $email = $this->input->post('email');
        $user = $this->getUserInfoByEmail($email);

        // メールアドレスが存在する場合
        if($user){
            $token = $this->createAccessToken($user->email, $user->password, time());
            $this->storeAccessToken($user->email, $token);
            $this->sendMailPasswordReset($user, $token);
        }

        return $this->redirectToPasswordSentPage();
    }


    private function getUserData($input)
    {
        $userName = $this->columnNameUserName;
        $item = $this->users->findBy($userName, $input[$userName]);

        return $item;
    }

    private function setUserInfo($item)
    {
        $this->session->is_verified = TRUE;

        $userInfo = array(
            'id' => $item->id,
            'label' => $item->label,
        );

        $this->session->set_userdata(parent::SESSION_NAME_USERINFO, $userInfo);

        return $this->session->sess_regenerate(TRUE);
    }

    /**
     * アクセストークンとメアドをパスワードリセット用テーブルに保存
     *
     * @params string $email
     * @params string $token
     * @return void
     */
    private function storeAccessToken($email, $token)
    {
        $now = date('Y-m-d H:i:s');

        try {
            $this->password_resets->email = $email;
            $this->password_resets->token = $token;
            $this->password_resets->created_at = $now;

            $this->db->trans_begin();
            $this->password_resets->insert();
            $this->db->trans_commit();
        }
        catch(Exception $e){
            $this->db->trans_rollback();
            echo $e->getMessage();
            return FALSE;
        }
    }

    private function getUserInfoByEmail($email)
    {
        return $this->users->findBy('email', $email);
    }

    private function sendMailPasswordReset($user, $token)
    {
        $serviceName = env('APP_NAME') . " CMS";

        $data = [
            'name' => $user->label,
            //'email' => $user->email,
            'reset_link' => $this->getPasswordResetLink($token),
            'service_name' => $serviceName,
            'company_name' => env('COMPANY_NAME'),
            'company_name_en' => env('COMPANY_NAME_EN'),
        ];

        $viewPath = 'admin/layouts/mails/password_reset'; 
        $message = $this->twig->render($viewPath, $data);

        $this->load->library('email'); // @see config/email.php

        $this->email->from(env('MAIL_FROM_ADDR'), env('MAIL_FROM_NAME'));
        $this->email->to($user->email);

        $this->email->subject("[" . $serviceName . "] パスワード再設定のご案内");
        $this->email->message($message);

        $ret = $this->email->send(); // boolean

        //var_dump($this->email->print_debugger()); die;

        return $ret;
    }

    /**
     * メールアドレス, パスワード, タイムスタンプ, ソルトを
     * 組み合わせてハッシュ文字列を作成する
     * [TODO] Users_modelに書くべき内容では?
     *
     * @params string $email
     * @params string $password
     * @params integer $time
     * @return string e.g. "f66407d441be589f5e0df4a6da2b46d0c1c051b29c271f125a93fd23325e269e"
     */
    private function createAccessToken($email, $password, $time)
    {
        $salt = $this->config->item('salt');
        $hashStr = $email . '|' . $password . '|' . $time . $salt;
        $token = hash('sha256', $hashStr);

        return $token; 
    }

    private function getPasswordResetLink($token)
    {
        $protocol = (! empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off' || $_SERVER['SERVER_PORT'] == 443) ? "https://" : "http://";
        $domain = $_SERVER['HTTP_HOST'];
        $path = base_url('admin/auth/password_reset/?token=' . $token);

        $url = $protocol . $domain . $path;
        return $url; // e.g. "https://www.example.com/cms/public/admin/auth/password_reset/?token=f66407d441be589f5e0df4a6da2b46d0c1c051b29c271f125a93fd23325e269e"
    }

    private function isValidEmailToken($currentToken)
    {
        // $currentToken GETの場合、クエリパラメータ POSTの場合、hidden値

        $item = $this->password_resets->findBy('token', $currentToken);

        //トークンが不正な場合
        if(empty($item) || $currentToken !== $item->token){
            return FALSE;
        }

        $dt = new DateTime($item->created_at);

        $time = (int) $dt->format('U'); // トークン発行日時 UNIX秒
        $now = time(); // ページアクセス日時 UNIX秒
        
        $authTime = (int) $this->config->item('auth_time'); // トークンの有効期限 UNIX秒

        $expired = $time + $authTime; // トークンの失効日時 UNIX秒

        // ページアクセス日時がトークン有効日時であるかをチェック
        // 条件の前半はページアクセス日時がトークンの発行日時以降であることを確認
        // 条件の後半はページアクセス日時がトークンの失効日時以内であることを確認
        if($time <= $now && $now < $expired){ // トークンが妥当な場合

            $this->token = $item->token; // TODO ここで設定するべきなのか?

            return TRUE;
        }
        else { // トークンが有効日時外である場合
            return FALSE;
        }
    }

    private function passwordResetAction()
    {
        // 基本的な入力バリデーションのチェック(DB接続なし) @see application/rules/admin/auth.php
        if(! $this->form_validation->run('password_reset')){
            return $this->redirectBackWithInput();
        }

        // より厳密な入力バリデーションのチェック(DB接続なし)
        $this->form_validation->set_rules('new_password', "新しいパスワード", 'callback_isValidPasswordPattern'); // @see isValidPasswordPattern();
        if(! $this->form_validation->run()){ // callback_isValidPasswordPattern
            return $this->redirectBackWithInput();
        }

        // アクセストークンからユーザー情報を取得
        $input = $this->input->post();

        $token = $this->token;

        $item = $this->password_resets->findBy('token', $token);
        $user = $this->getUserInfoByEmail($item->email);

        // パスワード再設定&使用済みのアクセストークン削除処理を実行

        try {
            $this->db->trans_begin();

            // パスワード再設定
            $this->users->password = $input['new_password'];
            $this->users->update($user->id);

            // 使用済みのアクセストークンを削除
            $this->password_resets->delete_record(array('token' => $token));

            $this->db->trans_commit();
        }
        catch(Exception $e){
            $this->db->trans_rollback();
            echo $e->getMessage();
            return FALSE;
        }

        // パスワード再設定が完了した時

        // 有効期限切れのアクセストークンをついでに削除
        $this->deleteExpiredAccessTokens();

        // ダッシュボードに遷移させる
        $this->setUserInfo($user);
        $this->setSuccessMessage("パスワード再設定が完了しました。");
        return $this->redirectToDashBoard();
    }

    /**
     * パスワード再設定メールを送ったが、パスワード再設定を完了しないで放置した場合
     * アクセストークンのレコードがDBに残り続ける
     * セキュリティ上よくない気がするので有効期限外のレコードは全削除することにした
     */
    private function deleteExpiredAccessTokens()
    {
        $now = time(); // 現在日時 UNIX秒

        $authTime = (int) $this->config->item('auth_time'); // トークンの有効期限 UNIX秒

        $expired = $now - $authTime; // 失効済のトークン日時 UNIX秒

        $dt = new DateTime();
        $dt->setTimestamp($expired);

        $expiredAt = $dt->format('Y-m-d H:i:s');

        try {
            $this->db->trans_begin();
            $this->db->where('created_at <', $expiredAt)->delete('password_resets');
            $this->db->trans_commit();
        }
        catch(Exception $e){
            $this->db->trans_rollback();
            echo $e->getMessage();
            return FALSE;
        }
    }

    /**
     * CodeIgniter Strong Password Validation
     * [TODO] Users.phpと同じ内容 できれば一つにまとめたい
     * @return bool
     */
    public function isValidPasswordPattern($password)
    {
        $msgLabel = "isValidPasswordPattern";
        $password = trim($password);

        $regex_lowercase = '/[a-z]/';
        $regex_uppercase = '/[A-Z]/';
        $regex_number = '/[0-9]/';
        $regex_special = '/[!@#$%^&*()\-_=+{};:,<.>§~]/';
        //$password_length_min = 8;
        //$password_length_max = 32;

        if (empty($password))
        {
            $this->form_validation->set_message($msgLabel, '{field}欄は入力必須項目です');
            return FALSE;
        }

        if (preg_match_all($regex_lowercase, $password) < 1)
        {
            $this->form_validation->set_message($msgLabel, '{field}欄は少なくとも1文字以上、アルファベット小文字を含む必要があります');
            return FALSE;
        }

        if (preg_match_all($regex_uppercase, $password) < 1)
        {
            $this->form_validation->set_message($msgLabel, '{field}欄は少なくとも1文字以上、アルファベット大文字を含む必要があります');
            return FALSE;
        }

        if (preg_match_all($regex_number, $password) < 1)
        {
            $this->form_validation->set_message($msgLabel, '{field}欄は少なくとも1文字以上、数字を含む必要があります');
            return FALSE;
        }

        if (preg_match_all($regex_special, $password) < 1)
        {
            $this->form_validation->set_message($msgLabel, '{field}欄は少なくとも1文字以上、次の記号のいずれかを含む必要があります' . ' ' . '!@#$%^&*()\-_=+{};:,<.>§~');
            return FALSE;
        }

        //if (strlen($password) < $password_length_min)
        //{
        //    $this->form_validation->set_message($msgLabel, 'The {field} field must be at least ' . $password_length_min . ' characters in length.');
        //    return FALSE;
        //}

        //if (strlen($password) > $password_length_max)
        //{
        //    $this->form_validation->set_message($msgLabel, 'The {field} field cannot exceed ' . $password_length_max . ' characters in length.');
        //    return FALSE;
        //}

        return TRUE;
    }
}
3
3
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
3
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?