WEB3 Connect PHP Passwordless Authentication

PHP Passwordless User Login with WEB3 Connect and Metamask

created December 23, 2021, last updated December 27, 2021.

.

WEB3 is becoming the buzzword of the year – if you don’t know exactly what it is join the club! Suffice to say that WEB3 will decentralise the Internet enabling us to do all the stuff we love on the Internet with a whole load of new decentralised blockchain applications (dapps).

If you have already used any dapps you will be familiar with crypto wallet apps such as MetaMask that allow you to access your accounts on various blockchains. I was interested in how these apps connect to MetaMask and came across a github project using MetaMask as a passwordless user authentication system for web apps and thought this would be a great way to do user logins for some of my applications.

The demo below shows how to connect with a web3 wallet and then authenticate the user to a PHP application by signing a message within the wallet to validate the account.

How it works

Let’s look at the workflow used by the client javascript and server php code.

First on the client we try and initialise a web3 provider and connect to a wallet with web3Connect(). This will fetch and initialise account information variables and update the gui. If no provider is found we launch a new Web3Modal window which is a single Web3 / Ethereum provider solution for all Wallets.

With the wallet connected and the account public address identified we can offer a user login using the public address as the unique identifier. First we call web3Login() which initiates the backend login process. We are using the axios plugin to post the login data to the backend php script which queries an sql database to check if the user account exists / creates a new account. The backend generates an Cryptographic nonce which is passed back as a sign request to the client. The client requests that the message be signed in the wallet and the signed response is sent back to the server to be authenticated.

We now have the server generated message, the same message signed by the user and the users public address. The backend performs some cryptographic magic in order to determine if the original message was signed with the same private key to which the public address belongs. The public address also works as a username to identify the account. If the signed message and public address belong to the same private key, it means that the user who is trying to log in is also the owner of the account.

After authentication the backend creates a JSON Web Token (JWT) to authenticate further user requests. PHP Session data is created by the backend which allows the authentication to persist between visits, with the backend using the JWT token to authenticate the user with each page request. The user is now logged in and the client updates the frontend gui accordingly.

To complete the demo a logout button is included to log the user out. In this demo anyone can create a new user account and “login”. In practice to restrict user access the backend would have a user approval process to enable new accounts, additionally user groups can be created to apply permissions to the account which are then used by the backend pages to determine which content is available to the user.

Client side js
"use strict";

/**
 *
 * WEB3 Application Wallet Connect / Passwordless client Login
 *
 *
 */

 // Unpkg imports
const Web3Modal = window.Web3Modal.default;
const WalletConnectProvider = window.WalletConnectProvider.default;
//const Fortmatic = window.Fortmatic;
const evmChains = window.evmChains;

// Web3modal instance
let web3Modal

// provider instance placeholder
let provider=false;

// Address of the selected account
let selectedAccount;

// web3 instance placeholder
let web3=false;

/**
 * ready to rumble
 */
jQuery(document).ready(function(){

    // initialise
    init();

});


/**
 * Setup the orchestra
 */
async function init() {

    // gui button events
    //
    $('#web3connect').on('click', function () {
        web3Connect();
    });

    $('#web3login').on('click', function () {
        web3Login();
    });

    $('#web3logout').on('click', function () {
        web3Logout();
    });

    //$('#web3disconnect').on('click', function () {
    //    web3Disconnect();
    //});

  console.debug("Initialising Web3...");
  console.log("WalletConnectProvider is", WalletConnectProvider);
  //console.log("Fortmatic is", Fortmatic);
  console.log("window.web3 is", window.web3, "window.ethereum is", window.ethereum);
  console.debug('' + web3App.loginstate);

  // Check that the web page is run in a secure context,
  // as otherwise MetaMask won't be available
  if(location.protocol !== 'https:') {
    // https://ethereum.stackexchange.com/a/62217/620
    console.debug('HTTPS not available, Doh!');
    return;
  }

  // Tell Web3modal what providers we have available.
  // Built-in web browser provider (only one can exist as a time)
  // like MetaMask, Brave or Opera is added automatically by Web3modal
  const providerOptions = {
    walletconnect: {
      package: WalletConnectProvider,
      options: {
        // test key
        infuraId: "8043bb2cf99347b1bfadfb233c5325c0",
      }
    },

    //fortmatic: {
    //  package: Fortmatic,
    //  options: {
        // TESTNET api key
    //    key: "pk_test_391E26A3B43A3350"
    //  }
    //}
  };

  // https://github.com/Web3Modal/web3modal
  //
  web3Modal = new Web3Modal({
    cacheProvider: true,
    providerOptions
  });

  console.log("Web3Modal instance is", web3Modal);

  if (web3Modal.cachedProvider) {
      console.debug('Cached Provider found');
      web3Connect();
      initPayButton();
  }
}


/**
 * Fetch account data for UI when
 * - User switches accounts in wallet
 * - User switches networks in wallet
 * - User connects wallet initially
 */
async function refreshAccountData() {

  // Disable button while UI is loading.
  // fetchAccountData() will take a while as it communicates
  // with Ethereum node via JSON-RPC and loads chain data
  // over an API call.
  updateGuiButton('web3connect','CONNECTING',true);

  await fetchAccountData(provider);

}

/**
 * Get account data
 */
async function fetchAccountData() {

  // init Web3 instance for the wallet
  if (!web3) {web3 = new Web3(provider);}
  console.log("Web3 instance is", web3);

  // Get connected chain id from Ethereum node
  const chainId = await web3.eth.getChainId();

  // Load chain information over an HTTP API
  let chainName='Unknown';

    try {
        const chainData = evmChains.getChain(chainId);
        chainName=chainData.name;
    } catch {
        // error...
    }

  console.debug('Connected to network : ' + chainName + ' [' + chainId + ']');

  // Get list of accounts of the connected wallet
  const accounts = await web3.eth.getAccounts();

  // MetaMask does not give you all accounts, only the selected account
  console.log("Got accounts", accounts);
  selectedAccount = accounts[0];

  web3.eth.defaultAccount = selectedAccount;

  console.debug('Selected account : ' + selectedAccount);

  // Go through all accounts and get their ETH balance
  const rowResolvers = accounts.map(async (address) => {

    web3App.ethAddress = address;

    const balance = await web3.eth.getBalance(address);
    // ethBalance is a BigNumber instance
    // https://github.com/indutny/bn.js/
    const ethBalance = web3.utils.fromWei(balance, "ether");
    const humanFriendlyBalance = parseFloat(ethBalance).toFixed(4);
    console.debug('Wallet balance : ' + humanFriendlyBalance);

  });

  // Because rendering account does its own RPC commucation
  // with Ethereum node, we do not want to display any results
  // until data for all accounts is loaded
  console.debug ('Waiting for account data...');

  await Promise.all(rowResolvers);

  // Update GUI - wallet connected
  //
  updateGuiButton('web3connect','CONNECTED',true);

  if (web3App.loginstate=='loggedOut')
  {
      updateGuiButton('web3login',false,false);
  } else {
      updateGuiButton('web3login',false,true);
  }

  console.debug ('Wallet connected!');

}

/**
 * Connect wallet
 *  when button clicked
 *  or auto if walletConnect cookie set
 */
async function web3Connect() {

  try {

    // if no provider detected use web3 modal popup
    //
    if (!provider)
    {
        console.log("connecting to provider...", web3Modal);
        console.debug("connecting to provider...");
        provider = await web3Modal.connect();
    }

    // Subscribe to accounts change
    provider.on("accountsChanged", (accounts) => {
      fetchAccountData();
      web3Disconnect();
      console.debug('Account changed to - ' + accounts);
    });

    // Subscribe to chainId change
    provider.on("chainChanged", (chainId) => {
      fetchAccountData();
      web3Disconnect();
      console.debug('Network changed to - ' + chainId);
    });

  } catch(e) {
    eraseCookie('walletConnect');
    console.debug("Could not get a wallet connection", e);
    return;
  }

  await refreshAccountData();
}

/**
 * web3 paswordless application login
 */
async function web3Login() {

  if (!provider){web3Connect();}

  let address=web3App.ethAddress;

  address = address.toLowerCase();
  if (!address | address == null) {
    console.debug('Null wallet address...');
    return;
  }

  console.debug('Login sign request starting...');

  axios.post(
    "/web3login/",
    {
      request: "login",
      address: address
    },
    web3App.config
  )
  .then(function(response) {
    if (response.data.substring(0, 5) != "Error") {
      let message = response.data;
      let publicAddress = address;

      handleSignMessage(message, publicAddress).then(handleAuthenticate);

      function handleSignMessage(message, publicAddress) {
        return new Promise((resolve, reject) =>
          web3.eth.personal.sign(
            web3.utils.utf8ToHex(message),
            publicAddress,
            (err, signature) => {
              if (err) {
                web3App.loginstate = "loggedOut";
                console.debug('' + web3App.loginstate);
              }
              return resolve({ publicAddress, signature });
            }
          )
        );
      }

      function handleAuthenticate({ publicAddress, signature }) {

        try {

            if (!arguments[0].signature){throw "Authentication cancelled, invalid signature"; }
            if (!arguments[0].publicAddress){throw "Authentication cancelled, invalid address"; }

            console.debug('Login sign request accepted...');

            axios
              .post(
                "/web3login/",
                {
                  request: "auth",
                  address: arguments[0].publicAddress,
                  signature: arguments[0].signature
                },
                web3App.config
              )
              .then(function(response) {

                console.log(response);

                if (response.data[0] == "Success") {

                  console.debug('Web3 Login sign request authenticated.');

                  web3App.loginstate = "loggedIn";
                  console.debug('' + web3App.loginstate);

                  web3App.ethAddress = address;
                  web3App.publicName = response.data[1];
                  web3App.JWT = response.data[2];

                  updateGuiButton('web3login','Logged in as ' + web3App.publicName,true);
                  updateGuiButton('web3logout',false,false);

                }
              })
              .catch(function(error) {
                console.error(error);
                updateGuiButton('web3login','LOGIN',false);
              });

          } catch(err) {
              console.error(err);
              updateGuiButton('web3login','LOGIN',false);
          }
      }

    }
    else {
      console.debug("Error: " + response.data);
    }

  })
  .catch(function(error) {
    console.error(error);
  });
}

/**
 * web3 Disconnect wallet
 */
async function web3Disconnect()
{
    console.debug("Killing the wallet connection");

    // TODO: Which providers have close method?
    if(provider) {
      provider = null;
      await web3Modal.clearCachedProvider();
    }

    localStorage.clear();
    selectedAccount = null;
    updateGuiButton('web3connect','CONNECT',false);
    console.debug("Disconnected");
}

/**
 * web3 Logout
 */
async function web3Logout()
{
    console.debug("Clearing server side sessions...");
    fetch('/web3logout/')
        .then((resp) => resp.json())
        .then(function(data) {

            // logged out
            //
            web3App.loginstate = "loggedOut";
            web3Disconnect();
            console.debug('' + web3App.loginstate);
            updateGuiButton('web3login','LOGIN',false);
            updateGuiButton('web3logout','LOGOUT',true);
        })
        .catch(function(error) {
          console.debug(error);
        });
}

/**
 * pay button
 */
const initPayButton = () => {
      $('#web3pay').click(() => {

        if (!provider){web3Connect();}

        console.debug('Requesting transaction signature...');

        const paymentAddress = '0x';
        const paymentAmount = 1;

        web3.eth.sendTransaction({
          to: paymentAddress,
          value: web3.utils.toWei(String(paymentAmount),'ether')
        }, (err, transactionId) => {
          if  (err) {
            console.debug('Payment failed', err.message);
          } else {
            console.debug('Payment successful', transactionId);
          }
        })
      })
    }

/**
 * update gui buttons
 */
function updateGuiButton(element,text,status)
{
    if (text)
    {
        $("#" + element).val(text);
    }

    // disabled button=true
    // enabled button=false
    if (status==true)
    {
        $("#" + element).prop("disabled",true).css("cursor", "default");
    } else {
        $("#" + element).prop("disabled",false).css("cursor", "pointer");
    }
}

/**
 * debug logger
 */
(function () {
    var logger = document.getElementById('log');
    console.debug = function () {
      for (var i = 0; i < arguments.length; i++) {

        if (web3App.debug)
        {
            console.log(arguments[i]);

            if (typeof arguments[i] == 'object') {
                logger.innerHTML = (JSON && JSON.stringify ? JSON.stringify(arguments[i], undefined, 2) : arguments[i]) + '\n' + logger.innerHTML;
            } else {
                logger.innerHTML = 'Web3App : ' + arguments[i] + '\n' + logger.innerHTML;
            }
        }
      }
    }
})();

Demo page design based on a template from HTML5UP

Comments

This site uses Akismet to reduce spam. Learn how your comment data is processed.