How to create a Magento 2.4 module
We will be making a password checker module. Our module will validate the customer’s password against the https://haveibeenpwned.com/ breached password list.
Create a Magento 2.4 module – Prerequisites
Step 1: Setting up our basic module
Before we can create a Magento 2.4 module we first need to understand the very basics of Magento.
A module in Magento 2 can exist one of two different locations
- Inside
app/code/CompanyName/ModuleName
- Inside
vendor/vendor-name/module-name
For this tutorial we will be using the first option, so inside of app/code/
.
The difference between both of these locations is that the second option is manageable through Composer. Ultimately, you’d want to have every third-party module inside the vendor
directory so that they can be maintainable. The first option is mainly used for your custom modules, which, in all fairness, you also want to have inside the vendor.
Using the app/code
option makes it easier for us to debug. When we pick the composer
option we need to update our code base every time using composer update
.
Making our startup files
To get our module to successfully be acknowledged as a module within Magento we need to have two files present in our module:
registration.php
- Composer includes this file for the autoloader
- This file quite literally registers our module as a module within Magento
etc/module.xml
- This file specifies the setup version and loading sequence of our module.
- Installation of the module triggers this sequence
- The list of modules is located in
etc/config.php
These two files are the core of every Magento 2 module so get accustomed to creating them.
Let’s start creating our module now. Inside your Magento 2 root directory locate the app/code
directory. If the code
directory doesn’t exist, don’t hesitate to create it yourself.
We will name our CompanyName
OBZ
and our ModuleName
PasswordBreachChecker
. After creating those directories your folder structure should look something like the following

Remember what we said before? A module requires two files in order to be acknowledged as a Magento module. So, let’s create those two files now.
app/code/OBZ/PasswordBreachChecker/registration.php
// app/code/OBZ/PasswordBreachChecker/registration.php <?php use Magento\Framework\Component\ComponentRegistrar; ComponentRegistrar::register(ComponentRegistrar::MODULE, 'OBZ_PasswordBreachChecker', __DIR__);
app/code/OBZ/PasswordBreachChecker/etc/module.xml
<!-- app/code/OBZ/PasswordBreachChecker/etc/module.xml --> <?xml version="1.0" ?> <config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:framework:Module/etc/module.xsd"> <module name="OBZ_PasswordBreachChecker"/> </config>
Note: the module.xml
file is situated in another directory called etc
. Create this directory first.
We have omitted a property from our module.xml
file called setup_version
. This property is required when you install new database tables or add new database columns to existing tables. Since we’re not going to do that in this tutorial, we don’t require this property.
Right now, we already have a working module. We can enable our module by running the following command inside your Magento 2 root directory
php bin/magento module_enable OBZ_PasswordBreachChecker
This command will register our module and add it to the list of enabled modules, see app/etc/config.php
for the status of all your modules.
Step 2: Creating our HaveIBeenPwned API endpoint
At this point, our module is active and running, but it doesn’t do a lot yet. Let’s fix that.
Since we are creating a module that checks if a customer’s password is in a list of breached passwords, we need to find a way to check the passwords. https://haveibeenpwned.com provides an API for us that we can use here https://haveibeenpwned.com/API/v2. We will be creating our own Magento 2 endpoint that will send a POST
request to the Have I Been Pwned API.
Making our API endpoint
Create two new directories inside our Magento 2 module directory called.
app/code/OBZ/PasswordBreachChecker/Api
- This directory manages our
contracts
that contain specific actions that can be utilized from various places in the application
app/code/OBZ/PasswordBreachChecker/Model
- This directory quite literally stores our models.
- Stores anything related to the data structure
- Handles loading of data
Besides these two files, we’ll also be making two files called
app/code/OBZ/PasswordBreachChecker/etc/webapi.xml
- This file defines our service contract, API endpoint URL, the request method, and the called function.
app/code/OBZ/PasswordBreachChecker/etc/di.xml
- This file contains our plugins and preferences.
- Used to link interfaces to classes.
We’ll start by making the two XML
files
app/code/OBZ/PasswordBreachChecker/etc/di.xml
<!-- app/code/OBZ/PasswordBreachChecker/etc/di.xml --> <?xml version="1.0" ?> <config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:framework:ObjectManager/etc/config.xsd"> <preference for="OBZ\PasswordBreachChecker\Api\CheckPasswordInterface" type="OBZ\PasswordBreachChecker\Model\CheckPassword"/> </config>
app/code/OBZ/PasswordBreachChecker/etc/webapi.xml
<!-- app/code/OBZ/PasswordBreachChecker/etc/webapi.xml --> <?xml version="1.0"?> <routes xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:module:Magento_Webapi:etc/webapi.xsd"> <route url="/V1/obz-password-breach-checker/check" method="POST"> <service class="OBZ\PasswordBreachChecker\Api\CheckPasswordInterface" method="check_password"/> <resources> <resource ref="anonymous"/> </resources> </route> </routes>
Next, make the following two files
app/code/OBZ/PasswordBreachChecker/Api/CheckPasswordInterface.php
// app/code/OBZ/PasswordBreachChecker/Api/CheckPasswordInterface.php <?php namespace OBZ\PasswordBreachChecker\Api; interface CheckPasswordInterface { /** * POST for Check Password Interface * @param string $password * @return mixed */ public function check_password($password); }
Note: The docbloc (the comment part) is required to install the module.
Second, is our model file. This one will implement the CheckPasswordInterface.php
file we just made and contains the actual logic.
app/code/OBZ/PasswordBreachChecker/Model/CheckPassword.php
// app/code/OBZ/PasswordBreachChecker/Model/CheckPassword.php <?php namespace OBZ\PasswordBreachChecker\Model; use Magento\Framework\HTTP\Client\Curl; class CheckPassword { private const HIBP_API_URI = 'https://api.pwnedpasswords.com'; private const HIBP_K_ANONYMITY_HASH_RANGE_LENGTH = 5; private const HIBP_K_ANONYMITY_HASH_RANGE_BASE = 0; private const PASSWORD_BREACHED = 'passwordBreached'; private const NOT_A_STRING = 'wrongInput'; protected $messageTemplates = [ self::PASSWORD_BREACHED => 'The provided password was found in previous breaches, please consider creating another password', self::NOT_A_STRING => 'The provided password is not a string, please provide a correct password', ]; /** * @var Curl */ protected $_curl; /** * CheckPassword constructor. * @param Curl $curl */ public function __construct( Curl $curl ) { $this->_curl = $curl; } /** * {@inheritDoc} */ public function check_password($password): string { return json_encode($this->isValid($password)); } /** * @inheritDoc */ private function isValid($value) : array { if (!is_string($value)) { return ['isValid' => false, 'message' => $this->messageTemplates[self::NOT_A_STRING]]; } if ($this->isPwnedPassword($value)) { return ['isValid' => false, 'message' => $this->messageTemplates[self::PASSWORD_BREACHED]]; } return ['isValid' => true]; } private function isPwnedPassword(string $password) : bool { $sha1Hash = $this->hashPassword($password); $rangeHash = $this->getRangeHash($sha1Hash); $hashList = $this->retrieveHashList($rangeHash); return $this->hashInResponse($sha1Hash, $hashList); } /** * We use a SHA1 hashed password for checking it against * the breached data set of HIBP. * @param string $password * @return string */ private function hashPassword(string $password) : string { $hashedPassword = sha1($password); return strtoupper($hashedPassword); } /** * Creates a hash range that will be send to HIBP API * applying K-Anonymity * * @see https://www.troyhunt.com/enhancing-pwned-passwords-privacy-by-exclusively-supporting-anonymity/ * @param string $passwordHash * @return string */ private function getRangeHash(string $passwordHash) : string { return substr($passwordHash, self::HIBP_K_ANONYMITY_HASH_RANGE_BASE, self::HIBP_K_ANONYMITY_HASH_RANGE_LENGTH); } /** * Making a connection to the HIBP API to retrieve a * list of hashes that all have the same range as we * provided. * @param string $passwordRange * @return string */ private function retrieveHashList(string $passwordRange) : string { $this->_curl->get(self::HIBP_API_URI . '/range/' . $passwordRange); return (string) $this->_curl->getBody(); } /** * Checks if the password is in the response from HIBP * @param string $sha1Hash * @param string $resultStream * @return bool */ private function hashInResponse(string $sha1Hash, string $resultStream) : bool { $data = explode("\r\n", $resultStream); $hashes = array_filter($data, static function ($value) use ($sha1Hash) { [$hash, $count] = explode(':', $value); return strcmp($hash, substr($sha1Hash, self::HIBP_K_ANONYMITY_HASH_RANGE_LENGTH)) === 0; }); return $hashes !== []; } }
This file is a lot to take in. What we’ve done here is, have taken the existing Laminas Undisclosedpassword
class and adjusted it to our own needs.
You can find this class here vendor/laminas/laminas-validator/src/UndisclosedPassword.php
.
What this model does is send a SHA1
request to the Have I Been Pwned API endpoint and returns whether the password has been breached or not.
We implement out Interface
we just made and write a short code block for the check_password
function. This is the function that gets executed whenever we do an API call.
Right now, we can already test our API endpoint.
Execute the following commands
bin/magento deploy:mode:set developer bin/magento cache:flush
Next, open up a REST API
client like https://www.postman.com/ or https://insomnia.rest/ and fill in the following fields

From this output we can see that the password password
has been found in a breach! If we try a completely random password, generated by our Python script we made in a different tutorial Random password generator – Python3, we get the following result

This means our password hasn’t been found in a breach on Have I Been Pwned!
Step 3: Checking customer passwords
Now that our API works, it’s time for the final step. This functionality should be ran whenever the customer creates an account, resets their password or when they want to change their password.
When doing any of those three actions above there’s already a password strength meter. We’ll be expanding that file with our breach check.
The Magento file which handles the password strength meter is called password-strength-indicator.js
and is located in vendor/magento/module-customer/view/frontend/web/js
. We do not want to directly edit this file as it’s in the vendor
directory. This means that our changes will be lost whenever we do a composer update
.
To extend this file, we will be using a mixin
. Mixins are one of a couple ways within Magento to extend, in this case, JavaScript files. To create a mixin we need a nifty file called requirejs-config.js
. Magento uses RequireJS as a means to load in different JavaScript libraries and files.
Making our frontend files
Create the following directory app/code/OBZ/PasswordBreachChecker/view/frontend
. Inside this directory create the following file requirejs-config.js
app/code/OBZ/PasswordBreachChecker/view/frontend/requirejs-config.js
// app/code/OBZ/PasswordBreachChecker/view/frontend/requirejs-config.js var config = { config: { mixins: { 'Magento_Customer/js/password-strength-indicator': { 'OBZ_PasswordBreachChecker/js/password-strength-indicator-mixin': true }, } } };
This file registers our mixin and sets it to active. Let’s create the above file now.
Create the following directory app/code/OBZ/PasswordBreachChecker/view/frontend/web/js
. Inside this directory create the following file password-strength-indicator-mixin.js
app/code/OBZ/PasswordBreachChecker/view/frontend/web/js/password-strength-indicator-mixin.js
// app/code/OBZ/PasswordBreachChecker/view/frontend/web/js/password-strength-indicator-mixin.js define([ 'jquery', 'Magento_Customer/js/zxcvbn', 'mage/translate', 'mage/url', 'mage/validation', ], function ($, zxcvbn, $t, urlBuilder) { 'use strict'; var passwordStrengthIndicatorWidgetMixin = { options: { passwordSelector: '[type=password]', passwordBreachedLabel: '#password-breach-label' }, _bind: function () { this._on(this.options.cache.input, { 'change': this._calculateStrength, 'keyup': this._calculateStrength, 'paste': this._checkPassword, 'focusout': this._checkPassword, }); if (this.options.cache.email.length) { this._on(this.options.cache.email, { 'change': this._calculateStrength, 'keyup': this._calculateStrength, 'paste': this._calculateStrength }); } }, _checkPassword: function () { this._calculateStrength(); this._checkBreached(); }, _checkBreached: function () { var self = this; var password = this._getPassword(), isEmpty = password.length === 0; if (isEmpty) { return false; } $.ajax({ url: urlBuilder.build('rest/V1/obz-password-breach-checker/check'), type: 'post', dataType: 'json', contentType: 'application/json', data: JSON.stringify({'password': password}), success: function (response) { var checkPasswordResponse = JSON.parse(response); var $passwordSelector = $(self.options.passwordSelector, self.element); var $passwordBreachedLabel = $(self.options.passwordBreachedLabel, self.element); if (!checkPasswordResponse.isValid) { $passwordBreachedLabel.text(checkPasswordResponse.message); $passwordSelector.css('background-color', '#e02b27'); } else { $passwordBreachedLabel.text(''); $passwordSelector.css('background-color', '#81b562'); } } }); } }; return function (targetWidget) { $.widget('mage.passwordStrengthIndicator', targetWidget, passwordStrengthIndicatorWidgetMixin); return $.mage.passwordStrengthIndicator; }; });
A lot is going on in this file but it’s not difficult. First we define our requirements and inject them into the function ($, zxcvbn, $t, urlBuilder)
. This is just a standard way of using RequireJS.
Next, we create our widget called passwordStrengthIndicatorWidgetMixin
. Inside of this widget we add our logic. We bind the initial jQuery events to our checkPassword
function. Whenever a customer pastes their password or tabs out of the password input field, our checkPassword
method is executed.
Because we still want the password strength meter to work after we’ve added our changes to the strength meter file, we let checkPassword
first do the strength check.
Inside _checkBreached
we create our API endpoint URL we made in the last section through the Magento URL builder. Next, we send out a POST request through AJAX and based on the response we update the background color of the password input field.
Finally, we return the Magento widget. We provide the name for the widget mage.passwordStrengthIndicator
, the base widget targetWidget
which is the original passwordStrengthIndicator
widget and finally a prototype which is our extended widget passwordStrengthIndicatorWidgetMixin
.
All that’s left to do is display the breached message in a nice way. For this we will create our last two directories app/code/OBZ/PasswordBreachChecker/view/frontend/layout
and app/code/OBZ/PasswordBreachChecker/view/frontend/templates/form
.
Add the following files to these directories.
app/code/OBZ/PasswordBreachChecker/view/frontend/layout/customer_account_create.xml
<!-- app/code/OBZ/PasswordBreachChecker/view/frontend/layout/customer_account_create.xml --> <?xml version="1.0"?> <page xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" layout="1column" xsi:noNamespaceSchemaLocation="urn:magento:framework:View/Layout/etc/page_configuration.xsd"> <body> <referenceContainer name="content"> <block class="Magento\Customer\Block\Form\Register" name="customer_form_register" template="OBZ_PasswordBreachChecker::form/register.phtml"> <arguments> <argument name="attribute_data" xsi:type="object">Magento\Customer\Block\DataProviders\AddressAttributeData</argument> </arguments> </block> </referenceContainer> </body> </page>
In this file, we tell Magento to use our own register.phtml
file instead of the default one.
app/code/OBZ/PasswordBreachChecker/view/frontend/templates/form/register.phtml
<!-- app/code/OBZ/PasswordBreachChecker/view/frontend/templates/form/register.phtml --> <?php /** * Copyright © Magento, Inc. All rights reserved. * See COPYING.txt for license details. */ use Magento\Customer\Block\Form\Register; use Magento\Customer\Block\Widget\Company; use Magento\Customer\Block\Widget\Dob; use Magento\Customer\Block\Widget\Fax; use Magento\Customer\Block\Widget\Gender; use Magento\Customer\Block\Widget\Name; use Magento\Customer\Block\Widget\Taxvat; use Magento\Customer\Block\Widget\Telephone; use Magento\Customer\Helper\Address; use Magento\Directory\Helper\Data; use Magento\Framework\Escaper; use Magento\Framework\View\Helper\SecureHtmlRenderer; /** @var Register $block */ /** @var Escaper $escaper */ /** @var SecureHtmlRenderer $secureRenderer */ /** @var Magento\Customer\Helper\Address $addressHelper */ $addressHelper = $block->getData('addressHelper'); /** @var Data $directoryHelper */ $directoryHelper = $block->getData('directoryHelper'); $formData = $block->getFormData(); ?> <?php $displayAll = $block->getConfig('general/region/display_all'); ?> <?= $block->getChildHtml('form_fields_before') ?> <?php /* Extensions placeholder */ ?> <?= $block->getChildHtml('customer.form.register.extra') ?> <form class="form create account form-create-account" action="<?= $escaper->escapeUrl($block->getPostActionUrl()) ?>" method="post" id="form-validate" enctype="multipart/form-data" autocomplete="off"> <?= /* @noEscape */ $block->getBlockHtml('formkey') ?> <fieldset class="fieldset create info"> <legend class="legend"><span><?= $escaper->escapeHtml(__('Personal Information')) ?></span></legend><br> <input type="hidden" name="success_url" value="<?= $escaper->escapeUrl($block->getSuccessUrl()) ?>"> <input type="hidden" name="error_url" value="<?= $escaper->escapeUrl($block->getErrorUrl()) ?>"> <?= $block->getLayout() ->createBlock(Name::class) ->setObject($formData) ->setForceUseCustomerAttributes(true) ->toHtml() ?> <?php if ($block->isNewsletterEnabled()): ?> <div class="field choice newsletter"> <input type="checkbox" name="is_subscribed" title="<?= $escaper->escapeHtmlAttr(__('Sign Up for Newsletter')) ?>" value="1" id="is_subscribed" <?php if ($formData->getIsSubscribed()): ?>checked="checked"<?php endif; ?> class="checkbox"> <label for="is_subscribed" class="label"> <span><?= $escaper->escapeHtml(__('Sign Up for Newsletter')) ?></span> </label> </div> <?php /* Extensions placeholder */ ?> <?= $block->getChildHtml('customer.form.register.newsletter') ?> <?php endif ?> <?php $_dob = $block->getLayout()->createBlock(Dob::class) ?> <?php if ($_dob->isEnabled()): ?> <?= $_dob->setDate($formData->getDob())->toHtml() ?> <?php endif ?> <?php $_taxvat = $block->getLayout()->createBlock(Taxvat::class) ?> <?php if ($_taxvat->isEnabled()): ?> <?= $_taxvat->setTaxvat($formData->getTaxvat())->toHtml() ?> <?php endif ?> <?php $_gender = $block->getLayout()->createBlock(Gender::class) ?> <?php if ($_gender->isEnabled()): ?> <?= $_gender->setGender($formData->getGender())->toHtml() ?> <?php endif ?> <?= $block->getChildHtml('fieldset_create_info_additional') ?> </fieldset> <?php if ($block->getShowAddressFields()): ?> <?php $cityValidationClass = $addressHelper->getAttributeValidationClass('city'); ?> <?php $postcodeValidationClass = $addressHelper->getAttributeValidationClass('postcode'); ?> <?php $regionValidationClass = $addressHelper->getAttributeValidationClass('region'); ?> <fieldset class="fieldset address"> <legend class="legend"><span><?= $escaper->escapeHtml(__('Address Information')) ?></span></legend><br> <input type="hidden" name="create_address" value="1" /> <?php $_company = $block->getLayout()->createBlock(Company::class) ?> <?php if ($_company->isEnabled()): ?> <?= $_company->setCompany($formData->getCompany())->toHtml() ?> <?php endif ?> <?php $_telephone = $block->getLayout()->createBlock(Telephone::class) ?> <?php if ($_telephone->isEnabled()): ?> <?= $_telephone->setTelephone($formData->getTelephone())->toHtml() ?> <?php endif ?> <?php $_fax = $block->getLayout()->createBlock(Fax::class) ?> <?php if ($_fax->isEnabled()): ?> <?= $_fax->setFax($formData->getFax())->toHtml() ?> <?php endif ?> <?php $_streetValidationClass = $addressHelper->getAttributeValidationClass('street'); ?> <div class="field street required"> <label for="street_1" class="label"> <span><?= /* @noEscape */ $block->getAttributeData()->getFrontendLabel('street') ?></span> </label> <div class="control"> <input type="text" name="street[]" value="<?= $escaper->escapeHtmlAttr($formData->getStreet(0)) ?>" title="<?= /* @noEscape */ $block->getAttributeData()->getFrontendLabel('street') ?>" id="street_1" class="input-text <?= $escaper->escapeHtmlAttr($_streetValidationClass) ?>"> <div class="nested"> <?php $_streetValidationClass = trim(str_replace('required-entry', '', $_streetValidationClass)); $streetLines = $addressHelper->getStreetLines(); ?> <?php for ($_i = 2, $_n = $streetLines; $_i <= $_n; $_i++): ?> <div class="field additional"> <label class="label" for="street_<?= /* @noEscape */ $_i ?>"> <span><?= $escaper->escapeHtml(__('Address')) ?></span> </label> <div class="control"> <input type="text" name="street[]" value="<?= $escaper->escapeHtml($formData->getStreetLine($_i - 1)) ?>" title="<?= $escaper->escapeHtmlAttr(__('Street Address %1', $_i)) ?>" id="street_<?= /* @noEscape */ $_i ?>" class="input-text <?= $escaper->escapeHtmlAttr($_streetValidationClass) ?>"> </div> </div> <?php endfor; ?> </div> </div> </div> <div class="field country required"> <label for="country" class="label"> <span><?= /* @noEscape */ $block->getAttributeData()->getFrontendLabel('country_id') ?></span> </label> <div class="control"> <?= $block->getCountryHtmlSelect() ?> </div> </div> <div class="field region required"> <label for="region_id" class="label"> <span><?= /* @noEscape */ $block->getAttributeData()->getFrontendLabel('region') ?></span> </label> <div class="control"> <select id="region_id" name="region_id" title="<?= /* @noEscape */ $block->getAttributeData()->getFrontendLabel('region') ?>" class="validate-select region_id"> <option value=""> <?= $escaper->escapeHtml(__('Please select a region, state or province.')) ?> </option> </select> <?= /* @noEscape */ $secureRenderer->renderStyleAsTag("display: none;", 'select#region_id') ?> <input type="text" id="region" name="region" value="<?= $escaper->escapeHtml($block->getRegion()) ?>" title="<?= /* @noEscape */ $block->getAttributeData()->getFrontendLabel('region') ?>" class="input-text <?= $escaper->escapeHtmlAttr($regionValidationClass) ?>"> <?= /* @noEscape */ $secureRenderer->renderStyleAsTag("display: none;", 'input#region') ?> </div> </div> <div class="field required"> <label for="city" class="label"> <span><?= /* @noEscape */ $block->getAttributeData()->getFrontendLabel('city') ?></span> </label> <div class="control"> <input type="text" name="city" value="<?= $escaper->escapeHtmlAttr($formData->getCity()) ?>" title="<?= /* @noEscape */ $block->getAttributeData()->getFrontendLabel('city') ?>" class="input-text <?= $escaper->escapeHtmlAttr($cityValidationClass) ?>" id="city"> </div> </div> <div class="field zip required"> <label for="zip" class="label"> <span><?= /* @noEscape */ $block->getAttributeData()->getFrontendLabel('postcode') ?></span> </label> <div class="control"> <input type="text" name="postcode" value="<?= $escaper->escapeHtmlAttr($formData->getPostcode()) ?>" title="<?= /* @noEscape */ $block->getAttributeData()->getFrontendLabel('postcode') ?>" id="zip" class="input-text validate-zip-international <?= $escaper->escapeHtmlAttr($postcodeValidationClass) ?>"> </div> </div> <?php $addressAttributes = $block->getChildBlock('customer_form_address_user_attributes');?> <?php if ($addressAttributes): ?> <?php $addressAttributes->setEntityType('customer_address'); ?> <?php $addressAttributes->setFieldIdFormat('address:%1$s')->setFieldNameFormat('address[%1$s]');?> <?php $block->restoreSessionData($addressAttributes->getMetadataForm(), 'address');?> <?= $addressAttributes->setShowContainer(false)->toHtml() ?> <?php endif;?> <input type="hidden" name="default_billing" value="1"> <input type="hidden" name="default_shipping" value="1"> </fieldset> <?php endif; ?> <fieldset class="fieldset create account" data-hasrequired="<?= $escaper->escapeHtmlAttr(__('* Required Fields')) ?>"> <legend class="legend"><span><?= $escaper->escapeHtml(__('Sign-in Information')) ?></span></legend><br> <div class="field required"> <label for="email_address" class="label"><span><?= $escaper->escapeHtml(__('Email')) ?></span></label> <div class="control"> <input type="email" name="email" autocomplete="email" id="email_address" value="<?= $escaper->escapeHtmlAttr($formData->getEmail()) ?>" title="<?= $escaper->escapeHtmlAttr(__('Email')) ?>" class="input-text" data-mage-init='{"mage/trim-input":{}}' data-validate="{required:true, 'validate-email':true}"> </div> </div> <div class="field password required"> <label for="password" class="label"><span><?= $escaper->escapeHtml(__('Password')) ?></span></label> <div class="control"> <input type="password" name="password" id="password" title="<?= $escaper->escapeHtmlAttr(__('Password')) ?>" class="input-text" data-password-min-length="<?= $escaper->escapeHtmlAttr($block->getMinimumPasswordLength()) ?>" data-password-min-character-sets="<?= $escaper->escapeHtmlAttr($block->getRequiredCharacterClassesNumber()) ?>" data-validate="{required:true, 'validate-customer-password':true}" autocomplete="off"> <div id="password-strength-meter-container" data-role="password-strength-meter" aria-live="polite"> <div id="password-strength-meter" class="password-strength-meter"> <?= $escaper->escapeHtml(__('Password Strength')) ?>: <span id="password-strength-meter-label" data-role="password-strength-meter-label"> <?= $escaper->escapeHtml(__('No Password')) ?> </span> </div> </div> <div id="password-breach-container" data-role="password-breach-checker" aria-live="polite"> <div id="password-breach" class="password-breach"> <span id="password-breach-label" data-role="password-breach-label"> </span> </div> </div> </div> </div> <div class="field confirmation required"> <label for="password-confirmation" class="label"> <span><?= $escaper->escapeHtml(__('Confirm Password')) ?></span> </label> <div class="control"> <input type="password" name="password_confirmation" title="<?= $escaper->escapeHtmlAttr(__('Confirm Password')) ?>" id="password-confirmation" class="input-text" data-validate="{required:true, equalTo:'#password'}" autocomplete="off"> </div> </div> </fieldset> <fieldset class="fieldset additional_info"> <?= $block->getChildHtml('form_additional_info') ?> </fieldset> <div class="actions-toolbar"> <div class="primary"> <button type="submit" class="action submit primary" title="<?= $escaper->escapeHtmlAttr(__('Create an Account')) ?>"> <span><?= $escaper->escapeHtml(__('Create an Account')) ?></span> </button> </div> <div class="secondary"> <a class="action back" href="<?= $escaper->escapeUrl($block->getBackUrl()) ?>"> <span><?= $escaper->escapeHtml(__('Back')) ?></span> </a> </div> </div> </form> <?php $ignore = /* @noEscape */ $_dob->isEnabled() ? '\'input[id$="full"]\'' : 'null'; $scriptString = <<<script require([ 'jquery', 'mage/mage' ], function($){ var dataForm = $('#form-validate'); var ignore = {$ignore}; dataForm.mage('validation', { script; if ($_dob->isEnabled()): $scriptString .= <<<script errorPlacement: function(error, element) { if (element.prop('id').search('full') !== -1) { var dobElement = $(element).parents('.customer-dob'), errorClass = error.prop('class'); error.insertAfter(element.parent()); dobElement.find('.validate-custom').addClass(errorClass) .after('<div class="' + errorClass + '"></div>'); } else { error.insertAfter(element); } }, ignore: ':hidden:not(' + ignore + ')' script; else: $scriptString .= <<<script ignore: ignore ? ':hidden:not(' + ignore + ')' : ':hidden' script; endif; $scriptString .= <<<script }).find('input:text').attr('autocomplete', 'off'); }); script; ?> <?= /* @noEscape */ $secureRenderer->renderTag('script', [], $scriptString, false) ?> <?php if ($block->getShowAddressFields()): ?> <?php $regionJson = /* @noEscape */ $directoryHelper->getRegionJson(); $regionId = (int) $formData->getRegionId(); $countriesWithOptionalZip = /* @noEscape */ $directoryHelper->getCountriesWithOptionalZip(true); ?> <script type="text/x-magento-init"> { "#country": { "regionUpdater": { "optionalRegionAllowed": <?= /* @noEscape */ $displayAll ? 'true' : 'false' ?>, "regionListId": "#region_id", "regionInputId": "#region", "postcodeId": "#zip", "form": "#form-validate", "regionJson": {$regionJson}, "defaultRegion": "{$regionId}", "countriesWithOptionalZip": {$countriesWithOptionalZip} } } } </script> <?php endif; ?> <script type="text/x-magento-init"> { ".field.password": { "passwordStrengthIndicator": { "formSelector": "form.form-create-account" } }, "*": { "Magento_Customer/js/block-submit-on-send": { "formId": "form-validate" } } } </script>
This file is a bit long, but the only important thing we’ve added here is.
<div id="password-breach-container" data-role="password-breach-checker" aria-live="polite"> <div id="password-breach" class="password-breach"> <span id="password-breach-label" data-role="password-breach-label"></span> </div> </div>
What we’ve done is copy the original register.phtml
file from vendor/magento/module-customer/view/frontend/templates/form
and added our password-breach-container
to it.
Execute the following commands in your root Magento directory and go to the Create an Account
page.
bin/magento deploy:mode:set developer bin/magento cache:flush
Fill in a simple password like password
and you should see the following.

Our final directory structures looks like this.

We have built a working password-checking module in Magento 2!
Next steps
What you may have noticed is that we didn’t do the templating for the forgot password
and edit account
pages. This is a good exercise to do by yourself to get better at Magento 2!