Yii2フレームワークことはじめ　その2 データベースを使ったユーザー登録と認証の実装

# イントロダクション
[前回の記事](https://qiita.com/Fu3nori/items/0d602634ae0592581ce6)でDBの作成と接続設定は完了しました。  
さて、問題はデータベースを使ったユーザー登録とログイン・ログアウトの方法です。  
デフォルトでYii2にはUserモデルとLoginFormモデル(app設置先/models)そしてcontrollers/SiteController.phpにloginアクションとlogoutアクションが用意されていますがこれはハードコードされたadminアカウントを返すだけなのでDBにUserテーブルを用意してユーザー登録とログイン、ログアウトを行うことができません。  
また、認証周りは予約語が多く、変数の定義やカラムの定義でシステムが落ちる事もあり、ここもはまりどころです。  

 加えて公式ドキュメントにも認証についてのトピックスは合っても実際の実装方法までは書かれていないので、ニックネームとメールアドレス、パスワードを登録し、メールアドレスとパスワードでログイン認証を行うサンプルコードを記載します。 

 # テーブル作成
 まず、前回作成したDBにターミナルで接続し、 use 前回作ったDB名;  
 を実行します。
 次に以下のSQL文を実行します。
 ```
SET SQL_MODE = "NO_AUTO_VALUE_ON_ZERO";
START TRANSACTION;
SET time_zone = "+00:00";

CREATE TABLE `user` (
  `id` int(11) NOT NULL COMMENT 'ユーザーID',
  `role` int(11) NOT NULL DEFAULT 0 COMMENT 'ユーザーロール',
  `mail` varchar(255) NOT NULL COMMENT 'ユーザーメールアドレス',
  `username` varchar(250) NOT NULL COMMENT 'ユーザー名',
  `password` varchar(255) NOT NULL COMMENT 'パスワード',
  `auth_key` varchar(32) NOT NULL COMMENT '認証キー',
  `ip` varchar(255) NOT NULL COMMENT 'IPアドレス',
  `created` datetime NOT NULL DEFAULT current_timestamp() COMMENT '登録日'
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='ユーザー情報テーブル';

ALTER TABLE `user`
  ADD PRIMARY KEY (`id`),
  ADD UNIQUE KEY `mail` (`mail`);

ALTER TABLE `user`
  MODIFY `id` int(11) NOT NULL AUTO_INCREMENT COMMENT 'ユーザーID';
COMMIT;
 ```
 ポイントはroleとauth_keyです。  
 auth_keyはYii2で提供される認証機能に使われる情報でこれがないと認証ができません。  
 必ず入れましょう。  
 roleは0が一般ユーザー、1が管理人として、セッションで取得して判別して使います。  
 一般ユーザーの登録と管理人登録の切替方法は後述します。  

 # コード一覧・モデル
 以下のようにモデルを作成・修正していきます。  
 models/LoginForm.php  
 ```
<?php
namespace app\models;

use Yii;
use yii\base\Model;
use app\models\User;

class LoginForm extends Model
{
    public $mail;
    public $password;
    public $rememberMe = true;
    public $username;
    private $_user = false;

    /**
     * @return array the validation rules.
     */
    public function rules()
    {
        return [
            // mail and password are both required
            [['mail', 'password'], 'required'],
            // rememberMe must be a boolean value
            ['rememberMe', 'boolean'],
            // password is validated by validatePassword()
            ['password', 'validatePassword'],
        ];
    }

    /**
     * Validates the password.
     * This method serves as the inline validation for password.
     *
     * @param string $attribute the attribute currently being validated
     * @param array $params the additional name-value pairs given in the rule
     */
    public function validatePassword($attribute, $params)
    {
        if (!$this->hasErrors()) {
            $user = $this->getUser();

            if (!$user || !$user->validatePassword($this->password)) {
                $this->addError($attribute, 'メールまたはパスワードが違います。');
            }
        }
    }

    /**
     * Logs in a user using the provided mail and password.
     * @return bool whether the user is logged in successfully
     */
    public function login()
    {
        if ($this->validate()) {
            return Yii::$app->user->login($this->getUser(), $this->rememberMe ? 3600*24*30 : 0);
        }
        return false;
    }

    /**
     * Finds user by [[mail]]
     *
     * @return User|null
     */
    public function getUser()
    {
        if ($this->_user === false) {
            $this->_user = User::findByEmail($this->mail);
        }

        return $this->_user;
    }
}
 ```
 今回は認証にメールアドレスを使うのでmailを定義しています。  

 models/User.php  
 ```
<?php
namespace app\models;

use Yii;
use yii\db\ActiveRecord;
use yii\web\IdentityInterface;

class User extends ActiveRecord implements IdentityInterface
{
    public static function tableName()
    {
        return 'user';
    }

    public function rules()
    {
        return [
            [['mail', 'username', 'password'], 'required'],
            [['mail'], 'email'],
            [['mail', 'username', 'password', 'auth_key'], 'string', 'max' => 255],
            [['mail'], 'unique'],
            [['password'], 'string', 'min' => 6],
        ];
    }

    public function setPassword($password)
    {
        $this->password = Yii::$app->security->generatePasswordHash($password);
    }

    public function generateAuthKey()
    {
        $this->auth_key = Yii::$app->security->generateRandomString();
    }

    public static function findByUsername($username)
    {
        return self::findOne(['username' => $username]);
    }

    public static function findByEmail($mail)
    {
        return self::findOne(['mail' => $mail]);
    }

    public function validatePassword($password)
    {
        return Yii::$app->security->validatePassword($password, $this->password);
    }

    public function beforeSave($insert)
    {
        if (parent::beforeSave($insert)) {
            if ($this->isNewRecord) {
                $this->generateAuthKey();
            }
            return true;
        }
        return false;
    }

    // IdentityInterfaceの実装
    public static function findIdentity($id)
    {
        return static::findOne($id);
    }

    public static function findIdentityByAccessToken($token, $type = null)
    {
        return static::findOne(['auth_key' => $token]);
    }

    public function getId()
    {
        return $this->id;
    }

    public function getAuthKey()
    {
        return $this->auth_key;
    }

    public function validateAuthKey($authKey)
    {
        return $this->getAuthKey() === $authKey;
    }
}
 ```
 mailとpasswordを受け入れるようにしています。  

# コード一覧・コントローラー

 controllers/UserController.php  
 ここではユーザー登録(actionRegist)で 
 https://設置ディレクトリ/web/user/regist  
 からユーザー登録を行う時「一番最初に登録したユーザー」にのみrole=1(管理者権限)を付与し、それ以降登録したユーザーは一般ユーザー(role=0)とするようにしています。    
 管理者を増やしたい等の要件であれば、セッション情報でroleが1のみアクセスできるactionを作ってそこからrole=1のユーザーを新規登録できるように改修すると良いでしょう。
 ```
<?php
namespace app\controllers;

use Yii;
use yii\web\Controller;
use app\models\User;
use yii\web\ForbiddenHttpException;

class UserController extends Controller
{
    public function actionLogin()
    {
        if (!Yii::$app->user->isGuest) {
            return $this->goHome();
        }

        $model = new \app\models\LoginForm();
        if ($model->load(Yii::$app->request->post()) && $model->login()) {
            return $this->goBack();
        }

        return $this->render('user-login', [
            'model' => $model,
        ]);
    }

    public function actionLogout()
    {
        Yii::$app->user->logout();
        return $this->goHome();
    }

    public function actionRegist()
    {
        $model = new User();
        if ($model->load(Yii::$app->request->post()) && $model->validate()) {
            $model->setPassword($model->password);
            $model->role = User::find()->count() === 0 ? 1 : 0; // 最初のユーザーのみ role を 1 にする
            $model->save(false);
            Yii::$app->user->login($model);
            return $this->redirect(['site/index']);
        }

        return $this->render('user-regist', [
            'model' => $model,
        ]);
    }

    public function actionEdit($id)
    {
        $currentUserId = Yii::$app->user->id;
        $currentUserRole = Yii::$app->user->identity->role;

        if ($currentUserRole != 1 && $currentUserId != $id) {
            throw new ForbiddenHttpException('アクセスが拒否されました');
        }

        $model = User::findOne($id);
        if (!$model) {
            throw new \yii\web\NotFoundHttpException('ユーザーが見つかりません');
        }

        $oldPassword = $model->password;

        if ($model->load(Yii::$app->request->post()) && $model->validate()) {
            if (!empty($model->password)) {
                $model->setPassword($model->password);
            } else {
                $model->password = $oldPassword;
            }
            $model->save(false);
            return $this->redirect(['site/index']);
        }

        $model->password = ''; // パスワードフィールドを空にしておく

        return $this->render('user-edit', [
            'model' => $model,
        ]);
    }


    public function actionList()
    {
        if (Yii::$app->user->isGuest || Yii::$app->user->identity->role != 1) {
            throw new ForbiddenHttpException('アクセスが拒否されました');
        }

        $users = User::find()->all();

        return $this->render('user-list', [
            'users' => $users,
        ]);
    }
}

 ```
 ここで定義されたアクションはユーザー登録 user/regist ユーザーログイン user/login ユーザー編集(自分のユーザーIDのみ、他人のIDの編集はできない) user/edit?id=ユーザーIDの値 ユーザーログアウト user/logout　ユーザー一覧(role=1の管理人のみアクセス可能) user/list です。  
 また、管理人がuser/list からは全てのユーザーのユーザー編集画面に遷移し編集できるようにしています。  

尚、ログイン・ログアウト後はデフォルトのYii2で用意されたsite/indexに遷移するようになっています。

 # コード一覧・view  

views/user/user-regist.php  
 ユーザー登録画面  
 ```
 <?php
use yii\helpers\Html;
use yii\bootstrap5\ActiveForm;

$this->title = 'ユーザー登録';
$this->params['breadcrumbs'][] = $this->title;
?>
<div class="user-regist">
    <h1><?= Html::encode($this->title) ?></h1>

    <div class="user-form">

        <?php $form = ActiveForm::begin(); ?>

        <?= $form->field($model, 'username')->textInput(['maxlength' => true]) ?>

        <?= $form->field($model, 'mail')->textInput(['maxlength' => true]) ?>

        <?= $form->field($model, 'password')->passwordInput() ?>

        <div class="form-group">
            <?= Html::submitButton('登録', ['class' => 'btn btn-success']) ?>
        </div>

        <?php ActiveForm::end(); ?>

    </div>
</div>

 ```
  
   
 views/user/user-login.php  
 ユーザーログイン画面  
 ```
<?php
use yii\helpers\Html;
use yii\bootstrap5\ActiveForm;

$this->title = 'ログイン';
$this->params['breadcrumbs'][] = $this->title;
?>
<div class="user-login">
    <h1><?= Html::encode($this->title) ?></h1>

    <p>以下のフィールドに入力してログインしてください:</p>

    <?php $form = ActiveForm::begin(['id' => 'login-form']); ?>

    <?= $form->field($model, 'mail')->textInput(['autofocus' => true]) ?>

    <?= $form->field($model, 'password')->passwordInput() ?>

    <?= $form->field($model, 'rememberMe')->checkbox() ?>

    <div class="form-group">
        <?= Html::submitButton('ログイン', ['class' => 'btn btn-primary', 'name' => 'login-button']) ?>
    </div>

    <?php ActiveForm::end(); ?>
</div>

 ```

 views/user/user-edit.php  
 ユーザー編集画面  
 ```
 <?php
use yii\helpers\Html;
use yii\bootstrap5\ActiveForm;

/** @var yii\web\View $this */
/** @var app\models\User $model */

$this->title = 'ユーザー編集';
$this->params['breadcrumbs'][] = $this->title;
?>
<div class="user-edit">
    <h1><?= Html::encode($this->title) ?></h1>

    <div class="user-form">

        <?php $form = ActiveForm::begin(); ?>

        <?= $form->field($model, 'mail')->textInput(['maxlength' => true]) ?>

        <?= $form->field($model, 'username')->textInput(['maxlength' => true]) ?>

        <!-- ここでパスワードを平文で表示 -->
        <?= $form->field($model, 'password')->textInput(['value' => $model->password, 'maxlength' => true]) ?>

        <div class="form-group">
            <?= Html::submitButton('保存', ['class' => 'btn btn-success']) ?>
        </div>

        <?php ActiveForm::end(); ?>

    </div>
</div>

 ```

views/user/user-list.php
ユーザー一覧(管理者のみアクセス可能、ここから各ユーザーの情報編集画面に繊維可能)
```
<?php
use yii\helpers\Html;

$this->title = 'ユーザー一覧';
$this->params['breadcrumbs'][] = $this->title;
?>
<div class="user-list">
    <h1><?= Html::encode($this->title) ?></h1>

    <table class="table table-striped">
        <thead>
        <tr>
            <th>ID</th>
            <th>メールアドレス</th>
            <th>ニックネーム</th>
            <th>登録日</th>
            <th>操作</th>
        </tr>
        </thead>
        <tbody>
        <?php foreach ($users as $user): ?>
            <tr>
                <td><?= Html::encode($user->id) ?></td>
                <td><?= Html::encode($user->mail) ?></td>
                <td><?= Html::encode($user->username) ?></td>
                <td><?= Html::encode($user->created) ?></td>
                <td><?= Html::a('編集', ['user/edit', 'id' => $user->id], ['class' => 'btn btn-primary']) ?></td>
            </tr>
        <?php endforeach; ?>
        </tbody>
    </table>
</div>
```
     
 # まとめ
 これでユーザー登録とログイン、ログアウト、編集、一覧表示が実施できるようになりました。  
 前回の解説でURLを/で切る形に設定済みですので、それぞれのURLは
 ユーザー登録  
 https://設置ディレクトリ/web/user/regist
   
 ユーザーログイン
 https://設置ディレクトリ/web/user/login

 ユーザーログアウト  
 https://設置ディレクトリ/web/user/logout

 ユーザー編集
 https://設置ディレクトリ/web/user/edit?id=自分のid

 となります。  
 なお、それぞれのページにリンクを張りたい場合はviews/site/index.phpを以下のように書き換えるとログイン状態、非ログイン状態に応じてユーザー登録画面、ユーザーログイン画面、ユーザー編集画面、ログアウトのリンクボタンを表示できるようになります。

views/site/index.php
```
<?php

/** @var yii\web\View $this */

use yii\helpers\Html;
use yii\helpers\Url;
use app\models\Profile;

$this->title = 'My Profile';
$userId = Yii::$app->user->id;
$profileExists = !Yii::$app->user->isGuest && Profile::find()->where(['user_id' => $userId])->exists();
?>
<div class="site-index">

    <div class="jumbotron text-center bg-transparent mt-5 mb-5">
        <h1 class="display-4">ダッシュボード</h1>
        <p class="lead">ここであなたのアカウントとプロフィールを管理して下さい</p>
    </div>

    <div class="body-content">
        <?php if (Yii::$app->user->isGuest): ?>
            <div style="float: left; padding: 5px;">
                <?= Html::a('アカウントを登録する', Url::to(['user/regist']), ['class' => 'btn btn-primary btn-lg']) ?>
            </div>
            <div style="float: left; padding: 5px;">
                <?= Html::a('ログインする', Url::to(['user/login']), ['class' => 'btn btn-primary btn-lg']) ?>
            </div>
        <?php else: ?>
            <div style="float: left; padding: 5px;">
                <?= Html::a('アカウント確認・編集', Url::to(['user/edit', 'id' => $userId]), ['class' => 'btn btn-primary btn-lg']) ?>
            </div>
            <div style="float: left; padding: 5px;">
                <?= Html::a('ログアウト', Url::to(['user/logout',]), ['class' => 'btn btn-primary btn-lg']) ?>
            </div>        
           
        <?php endif; ?>
        <div style="clear: both;"></div>
    </div>
</div>

```

 # 補足
 ユーザーがログインしたときのセッション情報は  
 ```
 use Yii;
 // 各種コードが続くとして
 $session = Yii::$app->session;
 $user_id = $session->get('id'); // セッションオブジェクトからidの値を取得する
 ```
 といった形で取得できるので応用してみて下さい。