We have our signature ready, and we are sending it to the backend. Now we need to:
- Verify the signature is authentic
- Check if the address matches an existing user in our database or otherwise create a new user
- Log the user in and redirect to the app dashboard
Let us get started.
Install dependencies
Before we get to the actual coding part, we will need to install a few dependencies.
composer require kornrunner/keccak --ignore-platform-reqs
composer require simplito/elliptic-php --ignore-platform-reqs
composer require kornrunner/keccak --ignore-platform-reqs
composer require simplito/elliptic-php --ignore-platform-reqs
composer require kornrunner/keccak --ignore-platform-reqs
composer require simplito/elliptic-php --ignore-platform-reqs
We are adding the --ignore-platform-reqs flag as composer would otherwise throw an error stating that the required ext-gmp extension is missing.
For this demo to work, the GMP extension is not required, and we can safely ignore it.
However, should you for any reason wish to install GMP anyway, you may find this
gist helpful.
Prepare the users table
Open the database/migrations/2014_10_12_000000_create_users_table.php file and replace the up method with the following code:
# database/migrations/2014_10_12_000000_create_users_table.
public function up()
{
Schema::create('users', function (Blueprint $table) {
$table->id();
$table->string('eth_address')->nullable();
$table->string('name')->nullable();
$table->string('email')->unique()->nullable();
$table->timestamp('email_verified_at')->nullable();
$table->string('password')->nullable();
$table->rememberToken();
$table->foreignId('current_team_id')->nullable();
$table->string('profile_photo_path', 2048)->nullable();
$table->timestamps();
});
}
# database/migrations/2014_10_12_000000_create_users_table.
public function up()
{
Schema::create('users', function (Blueprint $table) {
$table->id();
$table->string('eth_address')->nullable();
$table->string('name')->nullable();
$table->string('email')->unique()->nullable();
$table->timestamp('email_verified_at')->nullable();
$table->string('password')->nullable();
$table->rememberToken();
$table->foreignId('current_team_id')->nullable();
$table->string('profile_photo_path', 2048)->nullable();
$table->timestamps();
});
}
# database/migrations/2014_10_12_000000_create_users_table.
public function up()
{
Schema::create('users', function (Blueprint $table) {
$table->id();
$table->string('eth_address')->nullable();
$table->string('name')->nullable();
$table->string('email')->unique()->nullable();
$table->timestamp('email_verified_at')->nullable();
$table->string('password')->nullable();
$table->rememberToken();
$table->foreignId('current_team_id')->nullable();
$table->string('profile_photo_path', 2048)->nullable();
$table->timestamps();
});
}
The changes we have made here are:
- Added a eth_address field which will hold the user's ethereum address
- Made name , email , email_verified_at and password nullable
Remember to refresh the database afterwards using:
php artisan migrate:fre
php artisan migrate:fre
The login logic
Open the routes/web.php file and add the following line:
Add in the bottom of the file - after the Route::middleware(['auth:sanctum', 'verified'])->get(...) part
# routes/web.
Route::post('login-web3', \App\Actions\LoginUsingWeb3::class);
# routes/web.
Route::post('login-web3', \App\Actions\LoginUsingWeb3::class);
# routes/web.
Route::post('login-web3', \App\Actions\LoginUsingWeb3::class);
Note: We are using the routes/web.php file and not api.php because we will need access to the sesssion / cookie state in order to log in the user once authenticated.
Next let us go on and create the app/Actions/LoginUsingWeb3.php file which will hold the actual login logic. Copy / paste the following code into it:
# app/Actions/LoginUsingWeb3.
<?
namespace App\Actions;
use App\Models\User;
use Illuminate\Http\Request;
use Elliptic\EC;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Redirect;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
use kornrunner\Keccak;
class LoginUsingWeb3
{
public function __invoke(Request $request)
{
if (! $this->authenticate($request)) {
throw ValidationException::withMessages([
'signature' => 'Invalid signature.'
]);
}
Auth::login(User::firstOrCreate([
'eth_address' => $request->address
]));
return Redirect::route('dashboard');
}
protected function authenticate(Request $request): bool
{
return $this->verifySignature(
$request->message,
$request->signature,
$request->address,
);
}
protected function verifySignature($message, $signature, $address): bool
{
$messageLength = strlen($message);
$hash = Keccak::hash("\x19Ethereum Signed Message:\n{$messageLength}{$message}", 256);
$sign = [
"r" => substr($signature, 2, 64),
"s" => substr($signature, 66, 64)
];
$recId = ord(hex2bin(substr($signature, 130, 2))) - 27;
if ($recId != ($recId & 1)) {
return false;
}
$publicKey = (new EC('secp256k1'))->recoverPubKey($hash, $sign, $recId);
return $this->pubKeyToAddress($publicKey) === Str::lower($address);
}
protected function pubKeyToAddress($publicKey): string
{
return "0x" . substr(Keccak::hash(substr(hex2bin($publicKey->encode("hex")), 1), 256), 24);
}
}
# app/Actions/LoginUsingWeb3.
<?
namespace App\Actions;
use App\Models\User;
use Illuminate\Http\Request;
use Elliptic\EC;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Redirect;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
use kornrunner\Keccak;
class LoginUsingWeb3
{
public function __invoke(Request $request)
{
if (! $this->authenticate($request)) {
throw ValidationException::withMessages([
'signature' => 'Invalid signature.'
]);
}
Auth::login(User::firstOrCreate([
'eth_address' => $request->address
]));
return Redirect::route('dashboard');
}
protected function authenticate(Request $request): bool
{
return $this->verifySignature(
$request->message,
$request->signature,
$request->address,
);
}
protected function verifySignature($message, $signature, $address): bool
{
$messageLength = strlen($message);
$hash = Keccak::hash("\x19Ethereum Signed Message:\n{$messageLength}{$message}", 256);
$sign = [
"r" => substr($signature, 2, 64),
"s" => substr($signature, 66, 64)
];
$recId = ord(hex2bin(substr($signature, 130, 2))) - 27;
if ($recId != ($recId & 1)) {
return false;
}
$publicKey = (new EC('secp256k1'))->recoverPubKey($hash, $sign, $recId);
return $this->pubKeyToAddress($publicKey) === Str::lower($address);
}
protected function pubKeyToAddress($publicKey): string
{
return "0x" . substr(Keccak::hash(substr(hex2bin($publicKey->encode("hex")), 1), 256), 24);
}
}
# app/Actions/LoginUsingWeb3.
<?
namespace App\Actions;
use App\Models\User;
use Illuminate\Http\Request;
use Elliptic\EC;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Redirect;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
use kornrunner\Keccak;
class LoginUsingWeb3
{
public function __invoke(Request $request)
{
if (! $this->authenticate($request)) {
throw ValidationException::withMessages([
'signature' => 'Invalid signature.'
]);
}
Auth::login(User::firstOrCreate([
'eth_address' => $request->address
]));
return Redirect::route('dashboard');
}
protected function authenticate(Request $request): bool
{
return $this->verifySignature(
$request->message,
$request->signature,
$request->address,
);
}
protected function verifySignature($message, $signature, $address): bool
{
$messageLength = strlen($message);
$hash = Keccak::hash("\x19Ethereum Signed Message:\n{$messageLength}{$message}", 256);
$sign = [
"r" => substr($signature, 2, 64),
"s" => substr($signature, 66, 64)
];
$recId = ord(hex2bin(substr($signature, 130, 2))) - 27;
if ($recId != ($recId & 1)) {
return false;
}
$publicKey = (new EC('secp256k1'))->recoverPubKey($hash, $sign, $recId);
return $this->pubKeyToAddress($publicKey) === Str::lower($address);
}
protected function pubKeyToAddress($publicKey): string
{
return "0x" . substr(Keccak::hash(substr(hex2bin($publicKey->encode("hex")), 1), 256), 24);
}
}
There is a few things going on here, so let us break it down step-by-step.
The _invoke method
This is the entry-point for the route that we registered, and will receive the POST request sent from our frontend.
The actual logic is quite straight forward as we:
- Validate the signature sent from the frontend
- Find or create new user based on the user's address. When creating a new user, we will make sure to store the address in our dedicatedeth_address field.
- Log the user in, and redirect to the Jetstream dashboard
The verifySignature method
This is a standardized way of cryptographically validating that an Ethereum signature matches the corresponding message and address that signed it.
We will not fully go into the nitty gritty details of this code, (I am not a mathematician, and you do not need to be one either) but the high level explanation is that we are:
- Reconstructing a hash of the message
- Extracting the public key from the signature and hashed message
- Extracting the address from the public key
- Checking that the address sent from the frontend actually matches the address that signed the message
And voilá! We now have a functioning login!
If you go back to your browser and go through the login flow, you should now be redirected to the dashboard.
Bonus tip: disable Jetstream registration
If you actually intend to use Jetstream for your app and want to make sure your users always register with MetaMask the first time they login, you may wish to disable the default /register route that Jetstream ships with.
You can open config/fortify.php and comment out the "registration feature" line:
# config/fortify.
'features' => [
// Features::registration(),
Features::resetPasswords(),
[...]
],
# config/fortify.
'features' => [
// Features::registration(),
Features::resetPasswords(),
[...]
],
# config/fortify.
'features' => [
// Features::registration(),
Features::resetPasswords(),
[...]
],
Your users will now only be able to register using the MetaMask login.