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.
Let's look at the specifications of user authentication:
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.
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.
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.
Here's how the homepage should look like when opened in a browser (without the theme introduced in Chapter 4, The Renderer):
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):
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:
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.
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:
3.142.12.207