PHP
Phalcon

Phalconでログイン認証機能を実装

More than 1 year has passed since last update.

前回の続きです。環境、設定等はこちらを参照してください。→ DockerによるPhalcon(3.2.1)の開発環境構築(Ubuntu:16.04 + PHP7 + Nginx + MySQL)


公式のドキュメントをもとに Phalcon によるログイン認証機能を実装します。

公式の invo のチュートリアルでは、ハッシュ関数に SHA-1 が利用されていますが、実運用では BCrypt が推奨されています。よって、ここでは BCrypt を採用して実装します。


まず、app/config/loader.php を編集。

app/config/loader.php
$loader->registerDirs(
    [
        $config->application->controllersDir,
        $config->application->modelsDir,
        $config->application->pluginsDir     // これを追加
    ]
)->register();


app/config/services.php に以下を追記(最下部で OK)。

app/config/services.php
use Phalcon\Events\Manager as EventsManager;
use Phalcon\Mvc\Dispatcher;

$di->set('dispatcher', function () {
    $eventsManager = new EventsManager();

    $eventsManager->attach(
        'dispatch:beforeExecuteRoute',
        new SecurityPlugin()
    );

    $eventsManager->attach(
        'dispatch:beforeException',
        new NotFoundPlugin()
    );

    $dispatcher = new Dispatcher();

    $dispatcher->setEventsManager($eventsManager);

    return $dispatcher;
});

use Phalcon\Security;

$di->set('security', function () {
    $security = new Security();

    $security->setWorkFactor(12);

    return $security;
}, true);


ログイン認証用のプラグインファイル SecurityPlugin.php、NotFoundPlugin.php を作成。

# mkdir app/plugins && touch app/plugins/{SecurityPlugin.php,NotFoundPlugin.php}
app/plugins/SecurityPlugin.php
<?php

use Phalcon\Acl;
use Phalcon\Acl\Adapter\Memory as AclList;
use Phalcon\Acl\Resource;
use Phalcon\Acl\Role;
use Phalcon\Events\Event;
use Phalcon\Mvc\Dispatcher;
use Phalcon\Mvc\User\Plugin;

class SecurityPlugin extends Plugin
{
    public function getAcl()
    {
        if (!isset($this->persistent->acl)) {
            $acl = new AclList();
            $acl->setDefaultAction(Acl::DENY);

            $roles = [
                'admins' => new Role(
                    'Administrators',
                    'Super-User role'
                ),
                'users' => new Role(
                    'Users',
                    'Member privileges, granted after sign in.'
                ),
                'guests' => new Role(
                    'Guests',
                    'Anyone browsing the site who is not signed in is considered to be a "Guest".'
                )
            ];

            foreach ($roles as $role) {
                $acl->addRole($role);
            }

            $privateResources = [
                'index'      => ['index'],
                'users'      => ['index'],
                'session'    => ['end']
            ];
            foreach ($privateResources as $resource => $actions) {
                $acl->addResource(new Resource($resource), $actions);
            }

            $publicResources = [
                'users'      => ['new', 'create'],
                'session'    => ['index', 'start']
            ];
            foreach ($publicResources as $resource => $actions) {
                $acl->addResource(new Resource($resource), $actions);
            }

            $usersResources = [
                'index'      => ['index'],
                'session'    => ['end']
            ];

            foreach ($privateResources as $resource => $actions) {
                foreach ($actions as $action){
                    $acl->allow('Administrators', $resource, $action);
                }
            }

            foreach ($roles as $role) {
                foreach ($publicResources as $resource => $actions) {
                    foreach ($actions as $action){
                        $acl->allow($role->getName(), $resource, $action);
                    }
                }
            }

            foreach ($usersResources as $resource => $actions) {
                foreach ($actions as $action){
                    $acl->allow('Users', $resource, $action);
                }
            }

            $this->persistent->acl = $acl;
        }
        return $this->persistent->acl;
    }

    public function beforeExecuteRoute(Event $event, Dispatcher $dispatcher)
    {
        $auth = $this->session->get('auth');

        if (!$auth) {
            $role = 'Guests';
        } else {
            if ($auth['role'] === 'admins') {
                $role = 'Administrators';
            } elseif ($auth['role'] === 'users') {
                $role = 'Users';
            }
        }

        $controller = $dispatcher->getControllerName();
        $action     = $dispatcher->getActionName();

        $acl = $this->getAcl();

        $allowed = $acl->isAllowed($role, $controller, $action);

        if (!$allowed) {
            $this->flash->error(
                "You don't have access to this module"
            );

            $dispatcher->forward([
                'controller' => 'session',
                'action'     => 'index',
            ]);

            return false;
        }
    }
}
app/plugins/NotFoundPlugin.php
<?php

use Phalcon\Dispatcher;
use Phalcon\Events\Event;
use Phalcon\Mvc\Dispatcher as MvcDispatcher;
use Phalcon\Mvc\Dispatcher\Exception as DispatcherException;
use Phalcon\Mvc\User\Plugin;

class NotFoundPlugin extends Plugin
{
    public function beforeException(Event $event, MvcDispatcher $dispatcher, Exception $exception)
    {
        error_log($exception->getMessage() . PHP_EOL . $exception->getTraceAsString());
        if ($exception instanceof DispatcherException) {
            switch ($exception->getCode()) {
                case Dispatcher::EXCEPTION_HANDLER_NOT_FOUND:
                case Dispatcher::EXCEPTION_ACTION_NOT_FOUND:
                    $dispatcher->forward([
                        'controller' => 'errors',
                        'action' => 'show404'
                    ]);
                    return false;
            }
        }
        $dispatcher->forward([
            'controller' => 'errors',
            'action'     => 'show500'
        ]);
        return false;
    }
}


次に、前回作成したデータベース phalcon_sample にユーザー情報管理用の users テーブルを作成。

Adminer の画面より、「SQLコマンド」をクリックし、以下の SQL を入力/実行。

DROP TABLE IF EXISTS `users`;
CREATE TABLE `users` (
  `id` int(10) unsigned NOT NULL AUTO_INCREMENT,
  `username` varchar(255) COLLATE utf8_unicode_ci NOT NULL,
  `password` varchar(255) COLLATE utf8_unicode_ci NOT NULL,
  `role` varchar(255) COLLATE utf8_unicode_ci NOT NULL,
  `email` varchar(255) COLLATE utf8_unicode_ci NOT NULL,
  `created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
  `updated_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
  `active` char(1) COLLATE utf8_unicode_ci NOT NULL,
  PRIMARY KEY (`id`),
  UNIQUE KEY (`username`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_unicode_ci;

LOCK TABLES `users` WRITE;
INSERT INTO `users`
VALUES (1, 'phalcon_admin', '$2y$12$OUMyMWdSRUNCb2xsU0ZtbuV0TfeQ9A/vhO7jYYukqfWU4IctFUeZm', 'admins', 'admin@example.com', NOW(), NOW(), 'Y');
UNLOCK TABLES;

users テーブルと同時に、管理者権限を持ったユーザーが 1 レコード作成される(ユーザー名: phalcon_admin、パスワード: phalconsample)。


以下のコマンドを実行し users モデル(app/models/Users.php)を作成。中身は、先ほど作成した users テーブルの構造をもとに自動生成される。

# phalcon model users


以下のコマンドを実行し users コントローラー(app/controllers/UsersController.php)を作成。中身は自動生成されないので、以下を記述。

# phalcon controller users
app/controllers/UsersController.php
<?php

class UsersController extends \Phalcon\Mvc\Controller
{
    public function indexAction()
    {
        $users = Users::find();
        $this->view->users = $users;
    }

    public function newAction()
    {
    }

    public function createAction()
    {
        if (!$this->request->isPost()) {
            $this->dispatcher->forward([
                'controller' => 'users',
                'action' => 'new'
            ]);

            return;
        }

        $user = new Users();
        $user->username = $this->request->getPost('username');
        $password = $this->request->getPost('password');
        $user->password = $this->security->hash($password);
        $user->role = 'users';
        $user->email = $this->request->getPost('email', 'email');
        $user->active = 'Y';

        if (!$user->save()) {
            foreach ($user->getMessages() as $message) {
                $this->flash->error($message);
            }

            $this->dispatcher->forward([
                'controller' => 'users',
                'action' => 'new'
            ]);

            return;
        }

        $this->flash->success('user was created successfully');

        $this->dispatcher->forward([
            'controller' => 'session',
            'action' => 'index'
        ]);
    }
}


users 用の各種ビューファイル app/views/layouts/users.phtml、app/views/users/index.phtml、app/views/users/new.phtml を作成。

# mkdir app/views/users && touch app/views/{layouts/users.phtml,users/{index.phtml,new.phtml}}
app/views/layouts/users.phtml
<div class="row center-block">
    <?= $this->getContent() ?>
</div>
app/views/users/index.phtml
<table>
    <thead>
        <tr>
            <th>ID</th><th>Username</th>
        </tr>
    </thead>
    <tbody>
        <?php foreach($users as $user): ?>
            <tr>
                <td><?= $user->id ?></td><td><?= $user->username ?></td>
            </tr>
        <?php endforeach; ?>
    </tbody>
</table>
app/views/users/new.phtml
<h1>Create user</h1>

<?= $this->getContent() ?>

<?= $this->tag->form(['users/create', 'autocomplete' => 'off']) ?>
    <?= $this->tag->textField(['username', 'placeholder' => 'Username']) ?>
    <?= $this->tag->passwordField(['password', 'placeholder' => 'Password']) ?>
    <?= $this->tag->textField(['email', 'placeholder' => 'Email']) ?>
    <?= $this->tag->submitButton(['Create', 'class' => 'btn btn-default']) ?>
<?= $this->tag->endForm() ?>


次に、ログイン/ログアウト処理を実装します。

まずは、コントローラー app/controllers/SessionController.php を作成。

# phalcon controller session
app/controllers/SessionController.php
<?php

class SessionController extends \Phalcon\Mvc\Controller
{
    public function indexAction()
    {
    }

    private function _registerSession($user)
    {
        $this->session->set(
            'auth',
            [
                'id'   => $user->id,
                'role' => $user->role
            ]
        );
    }

    public function startAction()
    {
        if ($this->request->isPost()) {
            $username = $this->request->getPost('username');
            $password = $this->request->getPost('password');

            $user = Users::findFirstByUsername($username);

            if ($user) {
                if ($this->security->checkHash($password, $user->password) && $user->active === 'Y') {
                    $this->_registerSession($user);
                    $this->flash->success(
                        'Welcome ' . $user->username
                    );
                    return $this->dispatcher->forward([
                        'controller' => 'index',
                        'action'     => 'index'
                    ]);
                }
            } else {
                $this->security->hash(rand());
            }

            $this->flash->error(
                'Wrong username/password'
            );
        }

        return $this->dispatcher->forward([
            'controller' => 'session',
            'action'     => 'index'
        ]);
    }

    public function endAction()
    {
        $this->session->remove('auth');
        $this->flash->success('Goodbye!');
        return $this->dispatcher->forward(
            [
                'controller' => 'session',
                'action'     => 'index'
            ]
        );
    }
}


ログイン画面用ビューファイル app/views/layouts/session.phtml、app/views/session/index.phtml を作成。

# mkdir app/views/session && touch app/views/{layouts/session.phtml,session/index.phtml}
app/views/layouts/session.phtml
<div class="row center-block">
    <?= $this->getContent() ?>
</div>
app/views/session/index.phtml
<h1>Login</h1>

<?= $this->getContent() ?>

<?= $this->tag->form(['session/start', 'autocomplete' => 'off']) ?>
    <?= $this->tag->textField(['username', 'placeholder' => 'Username']) ?>
    <?= $this->tag->passwordField(['password', 'placeholder' => 'Password']) ?>
    <?= $this->tag->submitButton(['Login', 'class' => 'btn btn-default']) ?>
<?= $this->tag->endForm() ?>


app/views/index/index.volt を編集。

app/views/index/index.volt
<div class="page-header">
    <h1>Congratulations!</h1>
</div>

{{ content() }}     <!-- これを追加 -->

<p>You're now flying with Phalcon. Great things are about to happen!</p>

<p>This page is located at <code>views/index/index.volt</code></p>


ここで、一度ブラウザのキャッシュをクリアしてください。


次に、ユーザーを作成します。
「localhost:8080/users/new」へアクセスし、ユーザー名、パスワード、メールアドレスを入力してから「Create」をクリック。
ログイン画面へ遷移し「user was created successfully」と表示されれば、新規ユーザーの作成に成功。


今回実装したログイン認証機能は、ユーザーに割り当てられた二種類のロール(admins もしくは users)に対応した仕様になっています。
users テーブルと同時に作成したユーザー(ユーザー名: phalcon_admin、パスワード: phalconsample)のロールは admins で、users/new より作成されるユーザーのロールは全て users です。


現時点におけるパスとロールによるアクセス制限の関係は以下。

パス アクセス可のロール 処理
/ admins users デフォルトのページを表示
users/ admins 登録ユーザー一覧を表示
users/new ログイン不要 ユーザー登録画面を表示
users/create ログイン不要 ユーザー新規登録
session/ ログイン不要 ログイン画面を表示
session/start ログイン不要 ログイン実行
session/end admin users ログアウト実行


アクセス制限を変更するには、app/plugins/SecurityPlugin.php の SecurityPlugin クラスに定義されている getAcl() メソッドにて、\$privateResources、\$publicResources、\$usersResources の各配列にコントローラ名とアクション名を追加することで可能。

各配列変数とアクセス制限の関係は以下。

配列変数 アクセス制限
\$privateResources ロールが admins の場合にアクセス可能
\$publicResources ログイン不要でアクセス可能
\$usersResources ロールが users の場合にアクセス可能


最後に、ログイン処理におけるルーティングを設定します。

app/config/router.php を編集。

app/config/router.php
<?php

$router = $di->getRouter();

$router->addGet(
    '/login',
    'Session::index'
);

$router->addPost(
    '/login',
    'Session::start'
);

$router->addGet(
    '/logout',
    'Session::end'
);

$router->handle();

app/views/session/index.phtml を編集。

app/views/session/index.phtml
<h1>Login</h1>

<?= $this->getContent() ?>

<?= $this->tag->form(['login', 'autocomplete' => 'off']) ?>     <!-- url を変更 --!>
    <?= $this->tag->textField(['username', 'placeholder' => 'Username']) ?>
    <?= $this->tag->passwordField(['password', 'placeholder' => 'Password']) ?>
    <?= $this->tag->submitButton(['Login', 'class' => 'btn btn-default']) ?>
<?= $this->tag->endForm() ?>

以上です。
「localhost:8080/login」へアクセスするとログイン画面が表示され、「localhost:8080/logout」へアクセスするとログアウトします。