【備忘録】初めてのCakePHP③ - 認証編

# 開発環境
[【備忘録】初めてのCakePHP① - 環境構築編](https://qiita.com/kaba_farm/items/968e7d167089fcd96190)

# やりたいこと

新規登録、ログイン(username, password)、ログアウト

# 実装

```bash:/var/www/cake
$ bin/cake bake migration CreateUsers \
username:string[30] \
filename:string[255] \
password:string[255] \
role:string[20] \
created modified
```

```config/Migrations/xxxxxxxxx_CreateUsers.php
$table->addColumn('password', 'string', [
  'default' => null,
  'limit' => 255,
  'null' => true,  // falseからtrueに変える
]);
```

```bash:/var/www/cake
$ bin/cake migrations migrate
$ bin/cake bake model Users
```

#### モデル 
```php:src/Model/Table/UsersTable.php
class UsersTable extends Table
{
  /**
   * Initialize method
   *
   * @param array $config The configuration for the Table.
   * @return void
   */
  public function initialize(array $config)
  {
    parent::initialize($config);

    $this->setTable('users');
    $this->setDisplayField('id');
    $this->setPrimaryKey('id');

    $this->addBehavior('Timestamp');

    $this->hasMany('Articles', [
      'foreignKey' => 'user_id'
    ]);
  }

  public function validationDefault(Validator $validator)
  {
    $validator
      ->integer('id')
      ->allowEmptyString('id', null, 'create');

    $validator
      ->scalar('username')
      ->maxLength('username', 30)
      ->requirePresence('username', 'create')
      ->notEmptyString('username');

    $validator
      ->scalar('filename')
      ->maxLength('filename', 255)
      ->notEmptyString('filename');

    $validator
      ->scalar('password')
      ->maxLength('password', 255)
      ->requirePresence('password', 'create')
      ->notEmptyString('password');

    $validator
      ->scalar('role')
      ->maxLength('role', 20)
      ->requirePresence('role', 'create')
      ->notEmptyString('role');

    return $validator;
  }
```


#### パスワードはハッシュ化して保存 
```php:src/Model/Entity/User.php
  protected function _setPassword($password)
  {
    if (mb_strlen($password) > 0) {
      return (new DefaultPasswordHasher)->hash($password);
    }
  }
```


#### コントローラ

```bash:/var/www/cake
$ bin/cake bake controller UsersController
```

```php:src/Controller/AppController.php
  public function initialize()
  {
    parent::initialize();

    $this->loadComponent('RequestHandler', [
      'enableBeforeRedirect' => false,
    ]);
    $this->loadComponent('Flash');
    $this->loadComponent('Auth', [
      'authorize' => ['Controller'],
      // 認証後のリダイレクト先
      'loginRedirect' => [
        'controller' => 'Articles',
        'action' => 'index'
      ],
      // ログアウト後のリダイレクト先
      'logoutRedirect' => [
        'controller' => 'Users',
        'action' => 'login'
      ]
    ]);
    /*
     * Enable the following component for recommended CakePHP security settings.
     * see https://book.cakephp.org/3/en/controllers/components/security.html
     */
    //$this->loadComponent('Security');
  }

  public function beforeFilter(Event $event)
  {
    // index, showメソッドではログイン不要
    $this->Auth->allow(['index', 'show', 'display']);
  }
```

```php:src/Controller/UsersController.php
<?php

namespace App\Controller;

use App\Controller\AppController;
use Cake\Collection\Collection;
use Cake\Event\Event;
use Cake\Filesystem\Folder;
use Cake\Filesystem\File;

/**
 * Users Controller
 *
 * @property \App\Model\Table\UsersTable $Users
 *
 * @method \App\Model\Entity\User[]|\Cake\Datasource\ResultSetInterface paginate($object = null, array $settings = [])
 */
class UsersController extends AppController
{
  
  public function beforeFilter(Event $event)
  {
    parent::beforeFilter($event);
    $this->Auth->allow(['register', 'logout']);
  }

  public function login()
  {
    if ($this->request->is('post')) {
      $user = $this->Auth->identify();
      if ($user) {
        $this->Auth->setUser($user);
        return $this->redirect($this->Auth->redirectUrl());
      }
      $this->Flash->error(__('ログインIDまたはパスワードが一致しませんでした'));
    }
  }

  public function logout()
  {
    return $this->redirect($this->Auth->logout());
  }

  /**
   * Register method
   *
   * @return \Cake\Http\Response|null Redirects on successful add, renders view otherwise.
   */
  public function register()
  {
    $user = $this->Users->newEntity();
    if ($this->request->is('post')) {
      $user = $this->Users->patchEntity($user, $this->request->getData());
      if ($this->Users->save($user)) {
        $this->Flash->success(__('ユーザーが保存されました。'));
        return $this->redirect(['controller' => 'articles', 'action' => 'index']);
      }
      $this->Flash->error(__('ユーザーの保存に失敗しました。 お手数ですが、再度お試しください。'));
    }
    $this->set(compact('user'));
  }

}
```

##### ビュー

ログイン画面

```php:src/Template/Users/login.ctp
<div class="form my-4">
  <?= $this->Flash->render() ?>
  <fieldset class="card p-5">
    <?= $this->Form->create(); ?>
    <legend class="mb-5"><?= __('ログイン') ?></legend>
    <div class="form-outline input text mb-5">
      <input type="text" name="username" id="username" value="<?= $this->request->getData('username') ?? ''; ?>" class="form-control" />
      <label class="form-label" for="username">ログインID</label>
    </div>
    <div class="form-outline input text mb-5">
      <input type="password" name="password" id="password" value="<?= $this->request->getData('password') ?? ''; ?>" class="form-control" />
      <label class="form-label" for="password">パスワード</label>
    </div>
    <div class="text-center">
      <?= $this->Form->button(__('ログイン'), [
        'class' => 'btn btn-primary btn-rounded'
      ]); ?>
    </div>
    <?= $this->Form->end(); ?>
  </fieldset>
</div>
```

新規登録

```php:src/Template/Users/register.ctp
<div class="form my-4">
  <?= $this->Flash->render() ?>
  <fieldset class="card p-5">
    <?= $this->Form->create(); ?>
    <legend class="mb-5"><?= __('新規登録') ?></legend>
    <div class="form-outline mb-5">
      <input type="text" name="username" id="username" value="<?= $this->request->getData('username') ?? ''; ?>" class="form-control" />
      <label class="form-label" for="username">ログインID</label>
    </div>
    <div class="form-outline mb-5">
      <input type="password" name="password" id="password" value="<?= $this->request->getData('password') ?? ''; ?>" class="form-control" />
      <label class="form-label" for="password">パスワード</label>
    </div>
    <div class="col-12 mb-5">
      <label class="visually-hidden" for="inlineFormSelectPref">権限</label>
      <select name="role" class="form-select">
        <option value="admin">管理者</option>
        <option value="author">一般</option>
      </select>
    </div>
    <div class="text-center">
      <?= $this->Form->button(__('登録'), [
        'class' => 'btn btn-primary btn-rounded'
      ]); ?>
    </div>
    <?= $this->Form->end(); ?>
  </fieldset>
</div>
```

##### ロール

* 記事の追加は認証者全員ができる
* 記事の編集、削除は所有者ができる
* Adminロールのユーザは所有者でなくても記事の編集、削除ができる

```php:src/Model/Table/ArticlesTable.php
public function isOwnedBy($articleId, $userId)
{
  return $this->exists(['id' => $articleId, 'user_id' => $userId]);
}
```

```php:src/Controller/AppController.php

  public function initialize()
  {
    parent::initialize();

    $this->loadComponent('RequestHandler', [
      'enableBeforeRedirect' => false,
    ]);
    $this->loadComponent('Flash');
    $this->loadComponent('Auth', [
      'authorize' => ['Controller'],
      // 認証後のリダイレクト先
      'loginRedirect' => [
        'controller' => 'Articles',
        'action' => 'index'
      ],
      // ログアウト後のリダイレクト先
      'logoutRedirect' => [
        'controller' => 'Users',
        'action' => 'login'
      ]
    ]);
    /*
     * Enable the following component for recommended CakePHP security settings.
     * see https://book.cakephp.org/3/en/controllers/components/security.html
     */
    //$this->loadComponent('Security');
  }

  public function beforeFilter(Event $event)
  {
    // index, showメソッドではログイン不要
    $this->Auth->allow(['index', 'show', 'display']);
  }

  // 追加
  public function isAuthorized($user)
  {
    if (isset($user['role']) && $user['role'] === 'admin') {
      return true;
    }
    return false;
  }
```

```php:src/Controller/ArticlesController.php
class ArticlesController extends AppController
{
  public function isAuthorized($user)
  {
    if (in_array($this->request->getParam('action'), ['add'])) {
      return true;
    }

    if (in_array($this->request->getParam('action'), ['edit', 'delete'])) {
      $articleId = (int)$this->request->getParam('pass')[0];
      if ($this->Articles->isOwnedBy($articleId, $user['id'])) {
        return true;
      }
    }
    return parent::isAuthorized($user);
  }
}
```