[CakePHP]パスワードリマインダをつくったよ

#はじめに#
皆さんはパスワードを忘れたことはありますか？
私はあります。

そんな時に便利(?)なのがパスワードを忘れた方はこちらってやつですね。
いわゆるパスワードリマインダとかパスワードリセットとかいうやつです。
今回はCakePHPを使用し、それを作ります。

#いろいろなパスワードリマインダの方法#
##管理者向けパスワードリセット機能##
利用者は、パスワードを忘れた場合、管理者に問い合わせて対処してもらいます。
しかし、通常パスワードはハッシュ化されていて管理者さえもパスワードを知ることができないので、
通常はパスワードを再設定する方式が取られます。（今のパスワードがそのまま通知方式が採用されているならやばい)

パスワード再発行の順序として、

1. 問い合わせを受付、利用者の本人確認
2. 管理者がパスワードをリセットして、利用者に仮パスワードを伝える
3. 利用者は仮パスワードでログインして、直ちにパスワードを変更する

となります。

##利用者向けパスワードリセット機能##
こちらは利用者自らがパスワードをリセットします。
管理者向けパスワードリセット機能はすべてのアプリケーションが備えるべきですが、
利用者向けパスワードリセット機能はセキュリティ強度を下げる原因となるため、サイトの性質により、実装の是非を検討します。

###本人確認###
通常にアカウントに登録済みのメールアドレスによって本人確認をおこないます。
また、二段階認証を用いることで、本人確認を強化することができます。

しかし、メールは多くの場合平文で送信されることから盗聴のリスクはありますし、二段階認証で生成される
数字は6桁程度ですから、一般に強度不足です。

###パスワードの通知###
パスワードの通知方法には、以下の4種類が考えられます。
①現在のパスワードとメールで送信する。
　→現在のパスワードを知り得るということは、パスワードはハッシュ化されていないという不安に利用者に与える上、メールが盗聴された際のリスクが最も大きいため、最悪の方法です。
②推測困難なパスワード変更画面のURLで送付する。
　→今回実装する方式です。
③仮パスワードを発行して、メールで送信する。
　パスワードをメールで送るという点で、一見①の方法と変わりないと思われますが、仮パスワードが変更された段階で利用者にメールで通知することによって、不正利用を利用者が気づくことができます。
④パスワード変更画面に直接遷移する。
　→本人確認のメールアドレスが入力後、メールで受信確認のトークンを送付し、アプリケーションでトークンを入力することで、パスワード変更画面へ遷移する方式です。

この項目は、以下の書籍を参考にさせていただきました。
[体系的に学ぶ 安全なWebアプリケーションの作り方](http://www.amazon.co.jp/dp/B00E5TJ120)

#パスワードリマインダの実装#
##使用するテーブル##
###Users Table###
```sql
CREATE TABLE Users (
  id int(11) unsigned NOT NULL AUTO_INCREMENT,
  name varchar(20) NOT NULL,
  email varchar(255) NOT NULL UNIQUE,
  password varchar(255)  NOT NULL,
)
```
ハッシュ化された文字は通常60文字ですが、公式では今後アルゴリズムが変更になる可能性があるので、
２５５文字が適切だと書いてあります。

```PasswordReset.php
<?php
namespace App\Model\Entity;

use Cake\ORM\Entity;
use Cake\Auth\DefaultPasswordHasher;

class PasswordReset extends Entity
    protected function _setPassword($password)
    {
        if (strlen($password) > 0) {
          return (new DefaultPasswordHasher)->hash($password);
        }
    }
}
```
CakePHPでパスワードをハッシュ化するときには、Entityクラスでセッター機能を使うことで実現できます。
これは、newEntityやpatchEntityなどで、エンティティにセットタイミングでバリデーションの後に発動します。

ちなみに、DefaultPassWordHasherの内部では、passwordHashが呼ばれています。

```DefaultPassWordHaser.php
class DefaultPasswordHasher extends AbstractPasswordHasher
{
    protected $_defaultConfig = [
        'hashType' => PASSWORD_DEFAULT,
        'hashOptions' => []
    ];

    public function hash($password)
    {
        return password_hash(
            $password,
            $this->_config['hashType'],
            $this->_config['hashOptions']
        );
    }
```

password_hashの2つ目の引数は、ハッシュ化に使うアルゴリズムの定数を指定します。
PASSWORD_DEFAULTを指定しておけば、大丈夫です。
>PASSWORD_DEFAULT - bcrypt アルゴリズムを使います (PHP 5.5.0 の時点でのデフォルトです)。 新しくてより強力なアルゴリズムが PHP に追加されれば、 この定数もそれにあわせて変わっていきます。 そのため、これを指定したときの結果の長さは、変わる可能性があります。 したがって、結果をデータベースに格納するときにはカラム幅を 60 文字以上にできるようなカラムを使うことをお勧めします (255 文字くらいが適切でしょう)。
https://www.php.net/manual/ja/function.password-hash.php

3つ目の引数は、指定すると手動でソルトを設定することができます。**が、非推奨です。**
省略するとパスワードをハッシュするたびにランダムなソルトを自動生成します。

```UsersTable.php
public function validationDefault(Validator $validator)
{
    $validator
            ->notEmpty('password', 'パスワードが入力されていません。')
            ->minLength('password', 8,  'パスワードは8文字以上で入力してください。')
            ->alphaNumeric('password', 'パスワードには半角英数字のみ使用できます。')
            ->add('password', 'numberAndAlpha',[
                'rule' => function($data, $context) {
                        $valid = preg_match('/\A(?=.*?[a-z])(?=.*?\d)[a-z\d]/i', $data);
                        return $valid ? true : 'パスワードは英文字、数字それぞれ1文字以上含める必要があります。';
                    }
            ])
            ->sameAs('password', 'password_check', '確認用のパスワードと一致しません。');      
}
``` 

バリデーションは、tableクラスで定義します。
エンティティのバリデーションは、patchEntity、newEntity,またはsaveが呼ばれた時に実行されます。
validationの詳しい解説はまた今度にも。

###PasswordResets Table###
```sql
CREATE TABLE PasswordReset (
  id int(11) unsigned NOT NULL AUTO_INCREMENT,
  email varchar(255) NOT NULL,
  selector varchar(255)  NOT NULL,
  token varchar(255) NOT NULL,
  expire datetime NOT NULL
)
```
IDいらなくね？ってなるけどとりあえず規約には従っておきましょう。

```PasswordReset.php
<?php
namespace App\Model\Entity;

use Cake\ORM\Entity;
use Cake\Auth\DefaultPasswordHasher;

class PasswordReset extends Entity
    protected function _setToken($password)
    {
        if (strlen($password) > 0) {
          return (new DefaultPasswordHasher)->hash($password);
        }
    }
}
```
```PasswordResetsTable.php
変更点なし
```


#パスワードを忘れた方へのページ#
##View
はじめにこれがないと始まらないですね。
viewはざっくりと会員に紐付いているメールを送らせるFormを作ります。

```forget.ctp
<?php
echo $this->Form->create();
echo $this->Form->control('email', ['type' => 'email']);
echo $this->Form->end();
```

##Controller##
UserControllerのforgerメソッドで処理をします。

```UserController.php
<?php

namespace App\Controller;

use App\Controller\AppController;
use Cake\Mailer\Email;
use Cake\ORM\TableRegistry;
use Cake\Routing\Router;
use Cake\Network\Exception\NotFoundException;

class UserController extends AppController
{
    public function forget()
    {
        if ($this->request->is('post')) {
            $email = $this->request->getData('email');
            $users_table = TableRegistry::get('Users');
            $users_table = $users_table->find()
                ->where(['email ' => $email])
                ->first();

            if ($user) {
                $password_resets_table = TableRegistry::get('PassWordResets');
                $password_reset = $password_resets_table->find()
                    ->where(['email' => $email])
                    ->first();
                if ($password_reset){
                    // expireを更新するために、すでにテーブルに登録されていたら削除する
                    $password_resets_table->delete($password_reset);
                }
            } else {
                // 未登録のメールアドレスの場合は、なにもせずに結果画面を表示
                return $this->redirect('msg');
            }

            $data['email'] = $email;
            $data['selector'] = bin2hex(random_bytes(8));
            $data['token'] = random_bytes(32);
            $data['expire'] = date("Y-m-d H:i:s",strtotime("1 day"));
            $url = Router::url([
                'controller' => 'User',
                'action' => 'reset',
                '?' => ['selector' => $data['selector'], 'token' =>bin2hex($data['token'])],
            ], true);

            $password_reset = $password_resets_table->newEntity($data);
            $password_resets_table->save($password_reset);

            $email = new Email('default');
            $email->from(['me@example.com' => 'My Site'])
              ->to($email)
              ->subject('パスワード再発行のお知らせ')
            $email->emailFormat('text');                            
            $email->template('templete');                     
            $email->viewVars($url);
            return $this->redirect('msg');
        } 
    }
    public function msg()
    {
    }
}
```

postデータを受け取ったら、実際に会員データに登録されているメールアドレスか調べます。

```php

$email = $this->request->getData('email');
$users_table = TableRegistry::get('Users');
$users_table = $users_table->find()
   ->where(['email ' => $email])
 　->first();
```
会員のメールアドレスではなかった場合、当然メールは送信しませんが、
**必ずメール送信に成功した時と同じ画面を表示しましょう。**

```php
} else {
    // 未登録のメールアドレスの場合は、なにもせずに結果画面を表示
    return $this->redirect('msg');
}
```

これは、存在しないメールアドレスですなどのエラーメッセージを出してしまうと、
第三者にメールアドレスが登録していないことがわかってしまうからですね。

存在するメールアドレスなら、処理を続行します。
まず、すでにPassWordReset Tableに登録しているユーザーであるなら、これを削除します。
基本的に、パスワードの変更が完了した場合には、このテーブルの行は削除されますが、
新たにパスワード再発行依頼をしたときにこれを削除しないと、有効期限が上書きされません。

```php
if ($user) {
    $password_resets_table = TableRegistry::get('PassWordResets');
    $password_reset = $password_resets_table->find()
        ->where(['email' => $email])
        ->first();
    if ($password_reset){
        // expireを更新するために、すでにテーブルに登録されていたら削除する
        $password_resets_table->delete($password_reset);
    }
```

次に、トークンと有効期限を発行して、URLを生成します。
トークンにはselectorとtokenの2種類ありますね。これらはどう使われるのでしょうか。

```php

$data['email'] = $email;
$data['selector'] = bin2hex(random_bytes(8));
$data['token'] = random_bytes(32);
$data['expire'] = date("Y-m-d H:i:s",strtotime("1 day"));
$url = Router::url([
       'controller' => 'User',
       'action' => 'reset',
       '?' => ['selector' => $data['selector'], 'token' =>bin2hex($data['token'])],
       ], true);

$password_reset = $password_resets_table->newEntity($data);
$password_resets_table->save($password_reset);
```
URLを得るのは簡単です。Router::urlを使用します。
これは、controllerとactionを指定すると、自動でルーティングに基づいたURLを生成してくれます。
今回は、getパラメーターも指定したいので、'?'をキーに配列に加えましょう。
第2引数をtrueにすると、フルパスで生成することができます。。

tokenは、さきほどEntityクラスで見たように、DefaultPasswordHasherによってハッシュ化されます。
もしも、トークンの情報を盗み見られてしまった場合、トークンがそのまま保存されていたら誰でもそのURLにアクセスできてしまうので、
パスワードを乗っ取られてしまいます。

そのためトークンもパスワードと同じように、ハッシュ化して保存してあげる必要があるわけですね。
ハッシュ化した場合、そのトークンで直接データベースからデータをもってこれないので、
データベースから検索するようにselectorというトークンを使用するわけです。

あとは生成したURLを添付したメールを送ってあげましょう。
CakePHPのEメールに関しては公式の情報を参照してください。
https://book.cakephp.org/3/ja/core-libraries/email.html

##メールを受け取ったユーザーがパスワード再設定ページへアクセス##
ユーザーはこんな感じのメールを受け取るので、メール本文のURLへアクセスします。

>パスワード再発行のリクエストを受け付けました。
下記リンクをクリックしていただき、パスワード再設定の登録をお願いいたします。
*リンクの有効期限は、24時間です。
ttps://example.com/reset/?selector=4b148a081b9be8f9&token=e63f46d098187d3c0298cca07450963e32dd90d9d70e3f414e3d9f120be567a6
本メールにもしお心当たりのない場合、
恐れ入りますが破棄して頂けるようお願いいたします。

##View##
よくある、パスワードと確認用のパスワードを入力させるやつです。

```reset.ctp
<?php
echo $this->Form->create(['url' => ['action' => 'reset_ok']]);
echo $this->Form->control('password');
echo $this->Form->password('password_check');
echo $this->Form->end();
```

ここはgetメソッドだけで入ってこられるようにしたいので、post先は他の場所を指定します。

Form->controlを使うと、カラム名がpasswordなら、勝手にパスワードフォームになってくれます。
確認用のパスワード入力フォームのnameには、UsersTableのバリデーションのSameAsの第２引数で渡した名前にします。
これですね。

```php
->sameAs('password', 'password_check', '確認用のパスワードと一致しません。');  
```
バリデーションは、テーブルにないカラム名でも、同じrequestに入っていた時に一緒に来てくれます。

##Controller##
```UsersController.php
function reset() {
    if(!$this->request->isget()){
        throw new NotFoundException();
    }

    if (!$this->request->query('selector') || !$this->request->query('token')) {
        throw new NotFoundException();
    }

    $password_resets_table = TableRegistry::get('PassWordResets');
    $password_reset = $password_resets_table->find()
        ->where([
            'selector' => $this->request->query('selector'),
            'expire >=' => date("Y-m-d H:i:s"),
         ])
        ->first();

    if (!$password_reset) {
        throw new NotFoundException();
    }

    if (!password_verify(hex2bin($this->request->query('token')), $password_reset->token)){
        throw new NotFoundException();
    }
    $this->session->write('email', $password_reset->email);
}
```
まず、getメソッド以外はすべて突き返します。
NotFoundExceptionはその名の通り、404エラーを発生させます。

selectorまたはtokenが取得できなかったときも同様です。

requestの取得に成功した場合、トークンをもとにデータベースからどのユーザーのリクエストなのかを特定します。

```php
$password_resets_table = TableRegistry::get('PassWordResets');
$password_reset = $password_resets_table->find()
    ->where([
        'selector' => $this->request->query('selector'),
        'expire >=' => date("Y-m-d H:i:s"),
     ])
     ->first();
```

tokenはハッシュ化しているため、こいつをもとにデータを持ってくることはできません。
(password_hashは、毎回自動生成されたソルトをもとにハッシュ化しているので、同じ文字列をハッシュ化しても同じ二度と同じハッシュは得ることはできません）
そのために、selectorも用意したのでしたよね。

同時に有効期限が切れていないかどうかも調べます。

データを取得できなかったときもNotFoundを投げます。
(ところで有効期限切れのURL404でよかったんでしたっけ?よくわからんです。)

tokenが正しいかどうかは、password_verifyを使って調べます。

```php
if (!$password_reset) {
    throw new NotFoundException();
}

if (!password_verify(hex2bin($this->request->query('token')), $password_reset->token)){
    throw new NotFoundException();
}
$this->session->write('email', $password_reset->email);
```
getから送られてくるtokenは16進数化しているので、hex2binでバイナリ文字列にデコードする必要があることに注意しましょう。
password_verifyは第1引数にパスワードを、第2引数にハッシュ値をしていして、一致するとtrueを返します。

っていうかpassword_verifyってソルトとかどうやって計算してるの？なんかよくわからんけどすごい。

tokenも一致したらOK。セッションでemailを保持します。

#再設定のパスワード受け取り、登録#
```UsersController.php
function reset_ok() {
    if ($this->request->ispost()) {
        $email = $this->seeeion->read('email');
        $this->session->delete();
        if (!$email){
           throw new NotFoundException();
        }

        $users_table = TableRegistry::get('Users');
        $user = $users_table->find()
            ->where(['email' => $email])
            ->first();

        $users_table->patchEntity($user, $this->request->getData());
        if ($user->errors()) {
            return $this->redirect($this->referer());
        }
        $password_resets_table = TableRegistry::get('PassWordResets');
        $password_reset = $password_resets_table->find()
            ->where(['email' => $email])
            ->first();
        $password_resets_table->delete($password_reset);

        if ($users_table->save($user)) {
            $this->Flash->success(__('パスワードを変更しました。'));
            return $this->redirect(['action' => 'login']);
        } else {
            $this->Flash->error(__('パスワードの変更に失敗しました。');
            return $this->redirect(['action' => 'login']);
        }
    } else {
        throw new NotFoundException();
    }
}
```

postからパスワードを受け取って、セッションからemailを取得。
セッション切れなどでemailが取得できなかったら返してやりましょう。

そしたらemailからパスワードを変更するユーザーを取得します。

パスワードのバリデーションですが、これはUserstableで定義済みなので、pacthEntityしたら全部やってくれます。
で、errorsメソッドを使ってバリデーションエラーが発生したか判定すればOK。

```php
// controllerのバリデーションはこれだけでOK!
$users_table->patchEntity($user, $this->request->getData());
if ($user->errors()) {
   return $this->redirect($this->referer());
}
```

エラーメッセージもForm->controlを使用していれば勝手に出してくれます。超便利。

バリデーションも通ったら、PasswordResetテーブルからもトークンの情報を消し去りましょう。
これで一度パスワードを変更したら同じURLではアクセスできなくなります。


```php
$password_resets_table = TableRegistry::get('PassWordResets');
$password_reset = $password_resets_table->find()
    ->where(['email' => $email])
    ->first();
$password_resets_table->delete($password_reset);
```

あとはtableのsaveメソッドでパスワードを変更したら完了です。
loginページに飛ばしてやればいいんじゃないでしょうか。
これで終わりです。お疲れさまでした。
#最後に#
こちらのサイト(Youtube)を参考にさせていただきました。
[How To Create A Forgotten Password System In PHP | Password Recovery By Email In PHP | PHP Tutorial](https://www.youtube.com/watch?v=wUkKCMEYj9M&t=185s)