Create a popup menu with an Ajax login form so users can sign-in to your WordPress site without leaving the current page. This is a nice little UX improvement, because your customers can log into their account without leaving the current page/product/article.
importantMake sure you’re using a custom child theme, because we’re going to create several code files and edit functions.php.
importantYou should never pass login credentials over an unsecured connection, so make sure your site is using HTTPS.
The Project Requirements
- Automatically inject the login menu item into the site’s primary nav menu, but only if the user isn’t logged-in.
- Defend against brute force login attacks. We’ll do this by rate-limiting the login attempts per IP address to no more than one attempt every five seconds.
- We need a link to the site’s main login page (the front door).
- While a login is being attempted (and we’re waiting for a response from the server), show some visual feedback so the user knows something is happening.
- After a successful login, reload the current page.
- The mini HTML login form should be customisable, without having to change the project’s code.
Break it Down
This project needs to be part-PHP and part-JavaScript. We need to do the authentication stuff in the back-end, and handle the login form in the browser.
The back-end code has two main jobs:
- Create HTML that represents the login form as a sub menu.
- Respond to incoming Ajax calls from the login form and return the authentication cookie (if successful) or an error message (if failed).
The front-end (browser) code needs to:
- Scan the DOM for the login form, connect an event listener for the login button’s “click” event.
- Post the login request to the server when the user clicks the login button, or presses Enter in the username/password fields.
Scaffold the Code
In your custom child theme’s main folder, create a folder called “wpt-ajax-login”. In this folder, create two empty files called “wpt-ajax-login.css” and “wpt-ajax-login.js”.
Next up, create a file called “login-form.php” (in the same “wpt-ajax-login” folder) and paste the following into it:
<?php /** * Login form for the WP Tutorial AJAX Login tutorial for WordPress. * https://wp-tutorials.tech/optimise-wordpress/ajax-login-for-wordpress-without-a-plugin/ */ defined('WPINC') || die(); ?><p class="form-row"> <label for="wptajl-username"><?php esc_html_e('User Name', 'wp-tutorials');?></label> <input id="wptajl-username" name="username" type="text" placeholder="Username or email" /> </p> <p class="form-row"> <label for="wptajl-password"><?php esc_html_e('Password', 'wp-tutorials');?></label> <input id="wptajl-password" name="password" type="password" placeholder="Password" /> </p> <p class="form-row"> <button class="wptajl-login button"><?php esc_html_e('Login', 'wp-tutorials');?></button> </p>
Find (or create) a nice animated loading spinner SVG and place it in the same folder. If you’re not sure where to start, try one of Sam Herber’s cool SVG Loaders – I’m using “tail-spin.svg” in this tutorial.
Next we need to create the main PHP file. In your custom child theme’s main folder, create a file called wpt-ajax-login.php and paste the following into it.
<?php /** * WP Tutorials : Ajax Login (WPTAJL) * * https://wp-tutorials.tech/optimise-wordpress/ajax-login-for-wordpress-without-a-plugin/ */ // Prevent direct access to this file. defined('WPINC') || die(); // Consider changing these. const WPTAJL_MENU_LOCATION = 'primary'; const WPTAJL_LOGIN_RETRY_PAUSE = 5; // secs const WPTAJL_SPINNER_FILE_NAME = 'tail-spin.svg'; // You probably don't need to change these. const WPTAJL_LOGIN_ACTION = 'wptajl-login'; const WPTAJL_LOGIN_FORM_FILE_NAME = 'login-form.php';
Add Some Style
Open wpt-ajax-login/wpt-ajax-login.css and paste the following into it. This is just a starting point for your own login form.
/** * WP Tutorials : Ajax Login (WPTAJL) * * https://wp-tutorials.tech/optimise-wordpress/ajax-login-for-wordpress-without-a-plugin/ */ .sub-menu.wptajl-container { padding: 1em; box-shadow: 0 0 3em #00000022; min-width: 15em; position: absolute; } .wptajl-container input, .wptajl-container button { display: block; width: 100%; position: relative; transition: 0.3s; } .wptajl-container button { font-weight: bold; padding: 0.75em 0; } .wptajl-container label { font-size: 11pt; } .wptajl-container .form-row:not( :last-of-type) { margin-bottom: 1em; } .wptajl-container .form-row:last-of-type { margin-bottom: 0; } .wptajl-container button:disabled, .wptajl-container input:disabled { opacity: 0.75; } .wptajl-container .login-spinner { display: none; width: 1.5em; position: absolute; right: 0.5em; top: 50%; transform: translateY(-50%); } .wptajl-container.working .login-spinner { display: block; }
tipThis has been tested with the Astra theme and it works well. If you’re using something else, you might need to tweak/adjust/hack the CSS a bit.
The “wpt-ajax-login” folder in your custom child theme should look like this:
Reference the Code from functions.php
Finally, open your child theme’s functions.php and add the following lines to import our project:
// Ajax login require_once dirname(__FILE__) . '/wpt-ajax-login.php';
Save everything and reload a page on your site to make sure we’ve not broken anything. If it all still works, we can start filling in the blanks.
The Back-end PHP Code
We’re going to use the following WordPress hooks to modify our primary nav menu and respond to the Ajax login requests:
- init (action)
This is called by WordPress near the beginning of each page-load. This is where we connect our main login action listener. - wp_enqueue_scripts (action)
Triggered when WordPress wants to know which scripts and stylesheets to write into the page’s HTMLhead
element. - wp_nav_menu_items (filter)
If WordPress is trying to render the primary nav menu, and the user is not logged-in, we want to append some HTML to include our menu item and mini login form.
We’re also going to create some support/utility functions:
wptajl_client_ip()
: Return the browser’s current IP address.wptajl_is_login_menu_required()
: Returntrue
orfalse
, depending on whether the Ajax login form is required on this page-load.wptajl_front_door_url()
: A fall-back login page URL. Usually this is wp-login.php, but we can use WooCommerce’s login page if it’s available.
The core function will be called wptajl_try_to_login()
. All it has to do is check a couple of $_POST[]
items (user name & password) and return success/fail back to the browser.
Brute Force Login Protection
WordPress login forms are frequently attacked by armies of miscreant hack-bots, so we need to make sure we don’t add any security holes. We’re going to use WordPress transients to do this. A “transient” is just an object that exists for a finite amount of time – it can be seconds, years, or anything in between. Here’s the logic we’re going to implement:
- If the incoming Ajax action contains a user name and a password, then…
- Create a transient “key” based on the client’s IP address. This is just a string, something like ‘login_attempt_123.123.123.123″
- If a transient with this key already exists, then…
- This client has tried to log in (and failed) within the last 5 seconds, so don’t try to authenticate now.
- Set response.errorMessage to “Slow down a bit”
- else…
- Try to authenticate the user name and password
- If the user name and password are NOT valid, then…
- Create a new transient with the client’s key and set it to expire in five seconds time
- else…
- The user has logged-in successfully
So… if a hack-bot tries to fire 100 login attempts at the server over a five second timespan, we’ll try to authenticate the first attempt, and we’ll return errors for the next 99 attempts.
tipIf your hosting provider has Fail2Ban, you should look at installing a WordPress Fail2Ban plugin.
The Actual PHP Code
In your custom child theme’s main folder, open wpt-ajax-login.php and paste the following into it (replacing what was already in there):
<?php /** * WP Tutorials : Ajax Login (WPTAJL) * * https://wp-tutorials.tech/optimise-wordpress/ajax-login-for-wordpress-without-a-plugin/ */ // Prevent direct access to this file. defined('WPINC') || die(); // Consider changing these. const WPTAJL_MENU_LOCATION = 'primary'; const WPTAJL_LOGIN_RETRY_PAUSE = 5; // secs const WPTAJL_SPINNER_FILE_NAME = 'tail-spin.svg'; // You probably don't need to change these. const WPTAJL_LOGIN_ACTION = 'wptajl-login'; const WPTAJL_LOGIN_FORM_FILE_NAME = 'login-form.php'; /** * Listen for incoming login requests from the Ajax form. */ function wptajl_init() { add_action('wp_ajax_nopriv_' . WPTAJL_LOGIN_ACTION, 'wptajl_try_to_login'); } add_action('init', 'wptajl_init'); /** * Get the IP address of the current browser. * */ function wptajl_client_ip() { global $wptajl_client_ip; if (!is_null($wptajl_client_ip)) { // We've already discovered the browser's IP address. } elseif (!empty($_SERVER['HTTP_CLIENT_IP'])) { $wptajl_client_ip = filter_var($_SERVER['HTTP_CLIENT_IP'], FILTER_VALIDATE_IP); } elseif (!empty($_SERVER['HTTP_X_FORWARDED_FOR'])) { $wptajl_client_ip = filter_var($_SERVER['HTTP_X_FORWARDED_FOR'], FILTER_VALIDATE_IP); } else { $wptajl_client_ip = filter_var($_SERVER['REMOTE_ADDR'], FILTER_VALIDATE_IP); } return $wptajl_client_ip; } /** * A convenience function that lets us disable the login menu item under * certain conditions. */ function wptajl_is_login_menu_required() { $is_required = !is_user_logged_in(); // You can put extra conditions in here. // if ($some_test == true) { // $is_required = false; // } return $is_required; } function wptajl_front_door_url() { // Get the URL for the site's main login page, with the redirect // (after login) set to the page we're currently viewing. // This is useful for mobile devices where the Ajax login form might // not be available. $login_page_url = wp_login_url(home_url($_SERVER['REQUEST_URI'])); // If WooCommerce is installed, use the my-account page as the frontdoor, // so we get a nice front-end login form. if (function_exists('wc_get_account_endpoint_url')) { $frontdoor_url = wc_get_account_endpoint_url('dashboard'); } return $frontdoor_url; } /** * We add our assets to every page of the site, because the primay * nav menu is probably on every page. */ function wptajl_enqueue_scripts() { if (wptajl_is_login_menu_required()) { $base_uri = get_stylesheet_directory_uri(); $version = wp_get_theme()->get('Version'); // Our login form stylesheet. wp_enqueue_style( 'wptajl', $base_uri . '/wpt-ajax-login/wpt-ajax-login.css', null, // We don't have any style dependencies. $version ); // Enqueue our main JavaScript file. wp_enqueue_script( 'wptajl', $base_uri . '/wpt-ajax-login/wpt-ajax-login.js', array('jquery'), // Our code depends on jquery. $version ); // Pass some settings and variables to the browser in a // JavaScript global variable called wptajlData. wp_localize_script( 'wptajl', 'wptajlData', array( 'ajaxUrl' => admin_url('admin-ajax.php'), 'action' => WPTAJL_LOGIN_ACTION, 'frontDoor' => wptajl_front_door_url(), 'spinnerUrl' => $base_uri . '/wpt-ajax-login/' . WPTAJL_SPINNER_FILE_NAME, ) ); } } add_action('wp_enqueue_scripts', 'wptajl_enqueue_scripts'); /** * Render the login menu item and form. $items is a string that we're going * to append our HTML to. */ function wptajl_wp_nav_menu_items($items, $args) { // The file name of the login form (PHP) we're going to "include". $file_name = dirname(__FILE__) . '/wpt-ajax-login/' . WPTAJL_LOGIN_FORM_FILE_NAME; if (!wptajl_is_login_menu_required()) { // We're already logged in so we don't need a login form. } elseif ($args->theme_location != WPTAJL_MENU_LOCATION) { // These aren't the menu item's you're looking for. } elseif (!is_file($file_name)) { $items .= sprintf( '<li class="menu-item"><a class="menu-link"><strong>%s</strong></a></li>', WPTAJL_LOGIN_FORM_FILE_NAME ); } else { // Start rendering the HTML for the menu item. $outer_classes = array( 'menu-item', 'menu-item-has-children', 'menu-item-login', ); $items .= sprintf('<li class="%s">', esc_attr(implode(' ', $outer_classes))); // The login menu item. You can change the "Login" label here. $items .= sprintf( '<a href="%s">%s</a>', esc_url(wptajl_front_door_url()), esc_html__('Login', 'wp-tutorials') ); // Start rendering a sub menu to hold the login form. $sub_menu_classes = array( 'sub-menu', 'wptajl-container', ); $items .= sprintf('<ul class="%s">', esc_attr(implode(' ', $sub_menu_classes))); // Include the login form PHP/HTML file. ob_start(); include $file_name; $items .= ob_get_clean(); $items .= '</ul>'; // .sub-menu $items .= '</li>'; // .menu-item } return $items; } add_filter('wp_nav_menu_items', 'wptajl_wp_nav_menu_items', 10, 2); /** * The main function will try to log in to the site by sanitising and * authenticating $_POST['username'] and $_POST['password'] */ function wptajl_try_to_login() { // If we can't determine the client's IP address then something is very // wrong - possibly a hack attempt. Don't do anything. if (empty($client_ip_address = wptajl_client_ip())) { die(); } $client_key = 'login_attempt_' . $client_ip_address; $status_code = 200; $response = array( 'isLoggedIn' => false, 'errorMessage' => '', ); if ((get_transient($client_key) !== false)) { $response['errorMessage'] = 'Slow down a bit'; } elseif (empty($username = sanitize_text_field($_POST['username']))) { $response['errorMessage'] = 'No user name'; } elseif (empty($password = sanitize_text_field($_POST['password']))) { $response['errorMessage'] = 'No password'; } elseif (!is_a($user = wp_authenticate($username, $password), 'WP_User')) { $response['errorMessage'] = 'Invalid login'; } else { // Logged in OK. $response['isLoggedIn'] = true; // We need to do this so wp_send_json() can return the cookie in // our response. wp_set_auth_cookie( $user->ID, false// << This is the "remember me" option. ); do_action('wp_login', $username, $user); } if (!empty($response['errorMessage'])) { error_log($response['errorMessage']); set_transient($client_key, '1', WPTAJL_LOGIN_RETRY_PAUSE); } wp_send_json( $response, $status_code ); }
It’s a bit of a lump, but it’s properly commented and it should read well. Notice how the project’s settings are declared at the top, as constants.
WPTAJL_MENU_LOCATION
: Which menu location to inject. Default to “primary”.WPTAJL_LOGIN_RETRY_PAUSE
: The minimum number of seconds between login attempts for a given IP address.WPTAJL_SPINNER_FILE_NAME
: The name of the loader/spinner file in the wpt-ajax-login folder.
The JavaScript / Ajax Bit
Because we’re not using a standard HTML form
, we need to add some event-listeners to the text input controls and listen for Enter being pressed. When we detect it, we can trigger a login attempt by calling tryToLogin()
.
We also need to hide and show a working/loading “spinner”, which we’ll attach to the login button as an IMG
.
To make it all work, we’ll use jQuery.post() to send the login request to the server. While we’re waiting for a response, we’ll add the “working” CSS class to the sub-menu and disable the input/button elements.
Once the server has responded: If the attempt was successful, we’ll reload the current page. If not, we’ll put things back to how they were (remove “working” and re-enable the elements).
Open wpt-ajax-login/wpt-ajax-login.js and paste the following JavaScript code into it.
/** * WP Tutorials : Ajax Login (WPTAJL) * * https://wp-tutorials.tech/optimise-wordpress/ajax-login-for-wordpress-without-a-plugin/ */ (function($) { 'use strict'; $(window).on('load', function() { console.log('WPT Ajax Login : load'); /** * If wptajlData hasn't been set using wp_localize_script() then don't do anything. */ if (typeof wptajlData != 'undefined') { /** * Create and attach an IMG to each login form button (there should * only be one). */ $('.wptajl-login.button').each(function(index, el) { var spinner = $(`<img src="${wptajlData.spinnerUrl}" class="login-spinner" />`); $(this).append(spinner); }); /** * Listen for the "click" event on each login button. */ $('.wptajl-login.button').click(function(event) { event.preventDefault(); var container = $(this).closest('.wptajl-container'); tryToLogin(container); }); /** * For every input text|password element in our login form, listen * for the Enter key (ASCII code 13). */ $('.wptajl-container input[type="text"], .wptajl-container input[type="password"]').keypress(function(event) { if (event.which == 13) { event.preventDefault(); var container = $(this).closest('.wptajl-container'); tryToLogin(container); }; }); function tryToLogin(container) { // This is our request object. Each of these keys (action, // username & password) can be picked up in the PHP $_POST object. var request = { 'action': wptajlData.action, 'username': $(container).find('#wptajl-username').val(), 'password': $(container).find('#wptajl-password').val() }; if (!request.username || !request.password) { // Missing user name or password. // console.log('Missing user name and/or password.'); } else if ($(container).hasClass('working')) { // We've been asked to login, even though the login box // already has the "working" class attached to it. // This should never happen. // console.log('Already trying to log in. Please wait.'); } else { // console.log('Attempting to log in now.'); $(container).addClass('working'); $(container).find('input, button').prop('disabled', true); // Send our login request object to /wp-admin/admin-ajax.php $.post(wptajlData.ajaxUrl, request) .fail(function() { // There was an internal server error of some sort, // so direct the user to the main WP Login page. if (wptajlData.frontDoor) { window.location.href = wptajlData.frontDoor; } }) .done(function(response) { if (response.errorMessage) { alert(response.errorMessage); } else if (!response.isLoggedIn) { // Unknown error logging in. } else { // We've successfully logged-in. // Reload the current page. location.reload(); } }) .always(function(response) { if (!response.isLoggedIn) { $(container).find('input, button').prop('disabled', false); $(container).removeClass('working'); } }); } } } }); })(jQuery);
Notice the sections near the top that initialise the event listeners. Then there’s tryToLogin()
which does the main work. If you want to see the code in action, uncomment some of the console.log(...)
statements and check the JS Console in your browser.
Wrapping Up
If you’ve got a password-reset page (from WooCommerce or somewhere else), you can add a “Forgot your password?” link by editing login-form.php. Nice and easy.
To add a “Remember Me” checkbox, you could to do something like this:
- Add a checkbox into login-form.php and give it a sensible id.
- In wpt-ajax-login.js, detect if the checkbox is “checked” and add true|false to the request object, e.g.
$(container).find('#wptajl-remember-me').prop('checked') // Returns true or false
- In wpt-ajax-login.php, pick up the checkbox from
$_POST
, in the same way we pick up the user name and password (except this is a bool field, not a text field). - Pass this true|false value into
wp_set_auth_cookie()
Try to keep the login form simple. This is an unobtrusive feature that should improve the User eXperience.
That’s it. Happy logging-in! 😎 👍