Making the login interface

At last, we need to implement the feature we were interested in at the beginning: authenticating the user. First, we need to decide what it will mean for the user to be authenticated.

In the next chapter, which is dedicated to user authorization, we will check whether a user is allowed to do something in the system. But for now, we don't care about authorization, just the authentication, that is, some indication that the system recognized the user.

Specifications of user authentication

Let's look at the specifications of user authentication:

  • At each page of the application, in the top-right corner is the indicator of user authentication
  • When a user is not authenticated, the indicator holds the string guest and a link named login
  • When a user is authenticated, the indicator holds the user's username and a link named logout
  • A click on login leads to the Login page
  • A click on logout de-authenticates the user and redirects him to the homepage

This is pretty hard to comprehensively cover by use cases and the acceptance tests. As our goal is to learn about the features of Yii 2 that help us to implement stuff we want, let's settle with satisfying just the following acceptance test:

./cept generate:cept acceptance LoginAndLogout

Here are its full contents translated from the preceding high-level description:

$I = new AcceptanceTesterCRMUsersManagementSteps($scenario);
$I->wantTo('check that login and logout work'),

$I->amGoingTo('Register new User'),

$I->amInListUsersUi();
$I->clickOnRegisterNewUserButton();
$I->seeIAmInAddUserUi();
$user = $I->imagineUser();
$I->fillUserDataForm($user);
$I->submitUserDataForm();

$I = new AcceptanceTesterCRMUserSteps($scenario);
$I->amGoingTo('login'),

$I->seeLink('login'),
$I->click('login'),
$I->seeIAmInLoginFormUi();
$I->fillLoginForm($user);
$I->submitLoginForm();

$I->seeIAmAtHomepage();
$I->dontSeeLink('login'),
$I->seeUsername($user);
$I->seeLink('logout'),

$I->amGoingTo('logout from arbitrary page'),
$I->amInQueryCustomerUi();
$I->click('logout'),

$I->seeIAmAtHomepage();
$I->dontSeeUsername($user);
$I->dontSeeLink('logout'),
$I->seeLink('login'),

First, we create a user, then we try to login and see whether the indicator is reflecting the authentication, and then we try to logout and see whether the indicator is back to the state meant for guests.

The highlighted lines are the steps that are not defined yet. They are pretty straightforward.

Checking that we are in the Login Form UI:

    public function seeIAmInLoginFormUi()
    {
        $I = $this;
        $I->seeCurrentUrlEquals('/site/login'),
    }

Filling the login form with the data generated previously:

    public function fillLoginForm($user)
    {
        $I = $this;
        $I->fillField('LoginForm[username]', $user['UserRecord[username]']);
        $I->fillField('LoginForm[password]', $user['UserRecord[password]']);
    }

Note that the function shown in the preceding code is the exact duplicate of the similar "fill form" method in AcceptanceTesterCRMUsersManagementSteps. We skipped the refactoring step again.

Submit the login form using the submitLoginForm() method:

    public function submitLoginForm()
    {
        $I = $this;
        $I->click('button[type=submit]'),
        $I->wait(1);
    }

As we saw in Chapter 3, Automatically Generating the CRUD Code, the login form will have client-side validation too, so we are forced to wait.

Check that we are on homepage, that is, on the route /:

    public function seeIAmAtHomepage()
    {
        $I = $this;
        $I->seeCurrentUrlEquals('/'),
    }

Check that we have the username from the given fields somewhere on the page:

    public function seeUsername($user)
    {
        $I = $this;
        $I->see($user['UserRecord[username]']);
    }

Check that we don't see the same as above:

    public function dontSeeUsername($user)
    {
        $I = $this;
        $I->dontSee($user['UserRecord[username]']);
    }

This test fails at the step where we try to click the link named login. Let's add it to the layout.

Making the authentication indicator

As specified, the authentication indicator should show the guest text and the login link for the unauthorized users, and it should show the user's username text and the logout link for the authorized users.

Note

We can check whether the user is a guest using the following invocation:

Yii::$app->user->isGuest

So, the following HTML code in the views/layouts/main.php layout file right after the <div class="container"> opening tag will suffice:

        <div class="authorization-indicator">
            <?php if (Yii::$app->user->isGuest):?>
                <?= Html::tag('span', 'guest'),?>
                <?= Html::a('login', '/site/login'),?>
            <?php else:?>
                <?= Html::tag('span', Yii::$app->user->identity->username);?>
                <?= Html::a('logout', '/site/logout'),?>
            <?php endif;?>
        </div>

Now, given that we have ApplicationUiAssetBundle described in Chapter 4, The Renderer, we can add the following bit of CSS to assets/ui/css/main.css to make the authorization indicator right-aligned:

.authorization-indicator {
    float: right;
    width: 25%;
    text-align: right;
}

That's pretty rude, but for now our UI is rudimentary anyway, so it'll suffice.

The login form functionality

Here's how the homepage should look like when opened in a browser (without the theme introduced in Chapter 4, The Renderer):

The login form functionality

Now we need to make the login form functionality. As we already mentioned at two places in acceptance tests, the route to login is /site/login, so we need to provide the SiteController.actionLogin() method. Starting from here, we'll make the canonical login form implementation, which can be seen in the advanced application template from Yii 2. It's really hard to make password-based authentication in some other way in Yii, and to do so is unnecessary anyway.

Here's the logic for the traditional /site/login route handler:

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

    $model = new LoginForm();
    if ($model->load(Yii::$app->request->post()) and $model->login())
        return $this->goBack();

    return $this->render('login', compact('model'));
}

If a user is already authenticated, we redirect him back to the homepage using the yiiwebController.goHome() helper method that does the 302 redirect to the / root route for us.

If there's some data POSTed to us, we try to check with the LoginForm.login() method whether this data is sufficient to authenticate the user. In case of successful authentication, we redirect the user to the last URL visited by him by using the goBack() helper method. Otherwise, we render the login form HTML.

The most interesting part is the highlighted lines. We are going to utilize the power of models here. Using the same model, we'll both authenticate the user and manage the HTML form for providing username and password.

Let's start with the login form appearance in the views/site/login.php view file. A typical, minimal login form looks like this:

<?php
use yiihelpersHtml;
use yiiwidgetsActiveForm;

$form = ActiveForm::begin(['id' => 'login-form']);
echo $form->field($model, 'username'),
echo $form->field($model, 'password')->passwordInput();
echo $form->field($model, 'rememberMe')->checkbox();
echo Html::submitButton(
    'Login', 
    ['class' => 'btn btn-primary', 'name' => 'login-button']
);
ActiveForm::end();

There is the usual call to the ActiveForm widget and three calls to render different kinds of inputs. Note the style in which the fields are rendered. You can consult the documentation and/or source for the ActiveForm.field() method for details.

We have an additional checkbox for the "remember me" feature, which we have already prepared in the previous section.

The form described here looks like this (given our application's layout):

The login form functionality

You will not see it, although, until we will have the LoginForm class in place. It should be a model but not an ActiveRecord one, just a yiiaseModel.

This model has three fields, as expected in the view file. For our purposes, we need to provide the validation rules and the login() method we use in the SiteController.actionLogin().

This is the boilerplate of the LoginForm model, placed in models/user/LoginForm.php:

<?php
namespace appmodelsuser;

use yiiaseModel;

class LoginForm extends Model
{
    public $username;
    public $password;
    public $rememberMe;
}

For validation, we need three rules:

  • Both a username and a password are required
  • The "remember me" option is a Boolean (set/not set) value
  • The password should be valid, that is, if there's a user recorded for the given username, the given password should have the same hash as the one saved for this user (as explained before).

This can be expressed in the following way in the LoginForm class:

    public function rules()
    {
        return [
            [['username', 'password'], 'required'],
            ['rememberMe', 'boolean'],
            ['password', 'validatePassword']
        ];
    }

If the validator to apply is not one of the built-in ones, we need to provide at least a method in this model class with the same name as the validator. The second option is to make a full-fledged custom Validator class and register it for the Application. For now, we can do with the inline type of validators. The validatePassword() inline validator looks like this:

    public function validatePassword($attributeName)
    {
        if ($this->hasErrors())
            return;

        $user = $this->getUser($this->username);
        if (!($user and $this->isCorrectHash($this->$attributeName, $user->password)))
            $this->addError('password', 'Incorrect username or password.'),
    }

The guard case at the start is standard: if there are errors already, do not do anything. Then, we try to get UserRecord with the username provided. If such a record does not exist, or the provided password does not correspond to the hash stored in this record, we add an error to this model, what a proper validator should really do.

Getting the user record, given its username field value, can be done in any way, but for a nice clean solution, we decided to use lazy loading:

    /** @var UserRecord */
    public $user;

    private function getUser($username)
    {
        if (!$this->user)
            $this->user = $this->fetchUser($username);

        return $this->user;
    }

    private function fetchUser($username)
    {
        return UserRecord::findOne(compact('username'));
    }

To check whether the password provided is valid, the following implementation is sufficient:

    private function isCorrectHash($plaintext, $hash)
    {
        return Yii::$app->security->validatePassword($plaintext, $hash);
    }

That's the distilled core of our user authentication. In fact, everything until this point was just the support boilerplate code for it. All we really wanted to know is whether Security::validatePassword() returns true for the given plaintext version of the password and a hash taken from the database.

Then, at last, the login() method for LoginForm that is used in SiteController.actionLogin() concludes the mechanics of the login form:

   public function login()
    {
        if (!$this->validate())
            return false;

        $user = $this->getUser($this->username);
        if (!$user)
            return false;

        return Yii::$app->user->login(
            $user, 
            $this->rememberMe ? 3600 * 24 * 30 : 0
        );
    }

If the field values are not valid, obviously, don't allow the user to log in successfully. Finally, call the Yii built-in method for logging a user in and pass the fetched user record to it as identity. The second parameter is the time for which to keep the user session active (in seconds). A zero value means until the closure of the browser window. If "remember me" is set, we keep the user logged in for a month here.

The logout functionality and wrapping things up

Now we only need the logout handler, which can be done by the following almost one-liner method in the SiteController class:

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

This is the conclusion to the feature we've built so far. Now run the test:

./cept run tests/acceptance/LoginAndLogoutCept.php

It should pass, as shown in the following screenshot:

The logout functionality and wrapping things up
..................Content has been hidden....................

You can't read the all page of ebook, please click here login for view all page.
Reset
3.142.12.207