In my previous article, I provided information about WebAuthn. In this article, I will explain how to implement it with Laravel.
First, we include the laragear/webauthn package in our project.
composer require laragear/webauthn
return [
// ...
'providers' => [
'users' => [
'driver' => 'eloquent-webauthn',
'model' => App\User::class,
'password_fallback' => true,
],
]
];
php artisan webauthn:install
php artisan migrate
A table named webauthn_credentials will be created in the database. Let's create a new migration to add a device_name to this table.
php artisan make:migration webauthn_credentials_device_name
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('webauthn_credentials', function (Blueprint $table) {
$table->string('device_name')->nullable()->after('id');
$table->unsignedBigInteger('authenticatable_id')->index()->change();
$table->foreign('authenticatable_id')->references('id')->on('users')->onDelete('cascade');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('webauthn_credentials', function (Blueprint $table) {
$table->dropColumn('device_name');
$table->dropForeign(['authenticatable_id']);
$table->dropIndex(['authenticatable_id']);
});
}
};
php artisan migrate
After these operations, we update our User model as follows.
namespace App;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Laragear\WebAuthn\Contracts\WebAuthnAuthenticatable;
use Laragear\WebAuthn\WebAuthnAuthentication;
class User extends Authenticatable implements WebAuthnAuthenticatable
{
use WebAuthnAuthentication;
// ...
public function WebAuthn(): HasMany
{
return $this->hasMany(\App\Models\WebAuthnCredential::class, 'authenticatable_id')->select(['id', 'device_name']);
}
}
php artisan make:controller WebAuthn\WebAuthnLoginController
php artisan make:controller WebAuthn\WebAuthnRegisterController
php artisan make:controller WebAuthn\WebAuthnController
routes.php
Route::post('/login/first',
[\App\Http\Controllers\Auth\LoginController::class, 'loginFirst'])->name('login.first_step');
Route::post('/login',
[\App\Http\Controllers\Auth\LoginController::class, 'login']);
Route::post('/webauthn/login/options',
[\App\Http\Controllers\WebAuthn\WebAuthnLoginController::class, 'options'])
->withoutMiddleware(\Illuminate\Foundation\Http\Middleware\VerifyCsrfToken::class)
->name('webauthn.login.options');
Route::post('/webauthn/login',
[\App\Http\Controllers\WebAuthn\WebAuthnLoginController::class, 'login'])
->withoutMiddleware(\Illuminate\Foundation\Http\Middleware\VerifyCsrfToken::class)
->name('webauthn.login');
Route::post('/webauthn', [App\Http\Controllers\WebAuthn\WebAuthnController::class, 'WebAuthnList'])
->name('user.security.webauthn');
Route::post('/webauthn/delete', [App\Http\Controllers\WebAuthn\WebAuthnController::class, 'delete'])
->name('user.security.webauthn.delete');
Route::post('/webauthn/rename', [App\Http\Controllers\WebAuthn\WebAuthnController::class, 'rename'])
->name('user.security.webauthn.rename');
Route::post('/webauthn/register/options', [\App\Http\Controllers\WebAuthn\WebAuthnRegisterController::class, 'options'])
->withoutMiddleware(VerifyCsrfToken::class)
->name('webauthn.register.options');
Route::post('/webauthn/register', [\App\Http\Controllers\WebAuthn\WebAuthnRegisterController::class, 'register'])
->withoutMiddleware(VerifyCsrfToken::class)
->name('webauthn.register');
WebAuthnController
namespace App\Http\Controllers\WebAuthn;
use App\Http\Controllers\Controller;
use App\Models\WebAuthnCredential;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class WebAuthnController extends Controller
{
public function WebAuthnList()
{
return response()->json(auth()->user()->WebAuthn);
}
public function delete(Request $request, WebAuthnCredential $webauthn): JsonResponse
{
return (new \App\Action\WebAuthnAction())->delete($request, $webauthn, auth()->user());
}
public function rename(Request $request, WebAuthnCredential $webauthn): JsonResponse
{
return (new \App\Action\WebAuthnAction())->rename($request, $webauthn, auth()->user());
}
}
WebAuthnRegisterController
namespace App\Http\Controllers\WebAuthn;
use App\Models\User;
use Illuminate\Contracts\Support\Responsable;
use Illuminate\Http\Response;
use Laragear\WebAuthn\Http\Requests\AttestationRequest;
use Laragear\WebAuthn\Http\Requests\AttestedRequest;
use Laragear\WebAuthn\Models\WebAuthnCredential;
use function response;
class WebAuthnRegisterController
{
/**
* Returns a challenge to be verified by the user device.
*/
public function options(AttestationRequest $request): Responsable
{
return $request
->fastRegistration()
->toCreate();
}
/**
* Registers a device for further WebAuthn authentication.
*/
public function register(AttestedRequest $request): Response
{
$request->save();
WebAuthnCredential::latest()->first()->update(['device_name' => auth()->user()->nickname.'-'.rand().time()]);
User::where('id', auth()->user()->id)->update(['webauthn' => true]);
return response()->noContent();
}
}
WebAuthnLoginController
namespace App\Http\Controllers\WebAuthn;
use App\Models\User;
use Illuminate\Contracts\Container\BindingResolutionException;
use Illuminate\Contracts\Support\Responsable;
use Illuminate\Http\Response;
use Laragear\WebAuthn\Http\Requests\AssertedRequest;
use Laragear\WebAuthn\Http\Requests\AssertionRequest;
use function response;
class WebAuthnLoginController
{
/**
* Returns the challenge to assertion.
*
* @throws BindingResolutionException
*/
public function options(AssertionRequest $request): Responsable
{
return $request->toVerify($request->validate(['username' => 'sometimes|string']));
}
/**
* Log the user in.
*/
public function login(AssertedRequest $request): Response
{
$status = $request->login(remember:true) ? 204 : 422;
if ($status === 204) {
session()->put('otp', true);
}
return response()->noContent($status);
}
}
profile.blade.php
<div class="row">
<div class="col-12" id="webauthn_list">
</div>
@if(auth()->id() == $user->id)
<form id="register-form" method="post" action="javascript:void(0);">
@csrf
<button type="submit" class="btn btn-primary">@lang('webauthn.register_device')</button>
</form>
@endif
</div>
...
<style>
#webauthn_device_list, #webauthn_device_list li{
list-style: none;
padding: 0;
margin: 0;
}
#webauthn_device_list li:last-child{
border: 0 !important;
}
</style>
<script src="https://cdn.jsdelivr.net/npm/@laragear/webpass@2/dist/webpass.js"></script>
<script>
function notify_alert(message, type, log) {
if(log.success){
if (type === 'success') {
toastr.success(message, 'Success!', {
closeButton: true,
tapToDismiss: false
});
listWebauthn();
} else {
toastr.error(message, 'Error!', {
closeButton: true,
tapToDismiss: false
});
}
}
else{
toastr.error(log.error.message, 'Error!', {
closeButton: true,
tapToDismiss: false
});
}
console.log(log);
}
@if(auth()->id() == $user->id)
const attest = async () => await Webpass.attest(
{
path: "{{route('webauthn.register.options')}}",
body: {
username: '{{auth()->user()->username}}',
}
}, "{{route('webauthn.register')}}"
)
.then(response => notify_alert('{{__('webauthn.verification_success')}}', 'success', response))
.catch(error => notify_alert('{{__('webauthn.verification_failed')}}', 'error', error));
document.getElementById('register-form').addEventListener('submit', attest);
@endif
function deleteWebauthn(id, name) {
$('#delete_webauthn_id').val(id);
$('#delete_device_name').html(name);
$('#webauthnDeleteModal').modal('show');
}
function renameWebauthn(id, name) {
$('#rename_webauthn_id').val(id);
$('#rename_device_name').val(name);
$('#webauthnRenameModal').modal('show');
}
function listWebauthn() {
let url = '{{route('user.security.webauthn')}}';
$.ajax({
url: url,
method: 'POST',
data: {
_token: '{{ csrf_token() }}'
},
success: function (data) {
console.log(data);
let devices = '<ul id="webauthn_device_list">';
$.each(data, function (index, value) {
devices += '<li class="mt-1 pb-1 border-bottom">' +
'<div class="row"> ' +
'<div class="col-sm-12 col-md-7 mt-1">' + value.device_name + '</div>' +
'<div class="col-sm-12 col-md-5 text-end">' +
'<a href="javascript:renameWebauthn(\'' + value.id + '\', \'' + value.device_name + '\')" class="btn btn-primary">@lang('general.rename')</a> ' +
'<a href="javascript:deleteWebauthn(\'' + value.id + '\', \'' + value.device_name + '\')" class="btn btn-danger">' +
'<i class="fa-solid fa-trash-can"></i>' +
'</a> ' +
'</div> ' +
'</div>' +
'</li>';
});
devices += '</ul>';
$('#webauthn_list').html(devices);
}
});
}
$(document).ready(function () {
listWebauthn();
$('#delete-form').submit(function(){
let url = '{{route('user.security.webauthn.delete')}}';
$.ajax({
url: url,
method: 'POST',
data: $(this).serialize(),
success: function (data) {
if(data.status){
$('#webauthnDeleteModal').modal('hide');
listWebauthn();
}
}
});
});
$('#rename-form').submit(function(){
let url = '{{route('user.security.webauthn.rename')}}';
$.ajax({
url: url,
method: 'POST',
data: $(this).serialize(),
success: function (data) {
if(data.status){
$('#webauthnRenameModal').modal('hide');
listWebauthn();
}
}
});
});
})
</script>
After clicking the button, the WebAuthn functions on the browser side are activated through webpass.js, which we loaded onto the page, and it asks us to register a device. I designed the system based on the username, but by default, Laravel performs login checks using the email address. If you want to use email instead, you can change the body: { username: '{{auth()->user()->username}}', }
part in the JavaScript to email.
LoginController.php
public function loginFirst(Request $request)
{
$login = request()->input('username');
return response()->json($this->checkWebAuthn($login));
}
public function login(Request $request)
{
$login = request()->input('login');
$check_webauthn = $this->checkWebAuthn($login);
if($check_webauthn['status'] && $check_webauthn['webauthn']){
return response()->json($this->checkWebAuthn($login));
}
$request->validate([
'username' => 'required|string',
'password' => 'required',
]);
if (Auth::attempt(['username' => $request->username, 'password' => $request->password], true)) {
if (Hash::needsRehash(auth()->user()->password)) {
auth()->user()->password = Hash::make($request->password);
auth()->user()->save();
}
return response()->json([
'status' => true,
'webauthn' => false,
'message' => __('user.login_request.success'),
]);
}
return response()->json([
'status' => false,
'webauthn' => false,
'message' => __('user.login_request.warning'),
], 401);
}
login.blade.php
<form method="post" action="javascript:void(0)" id="first_step">
<div class="input-group mb-3">
<input type="text" name="username" class="form-control"
required
placeholder="@lang('user.username')"
id="username"
aria-label="username">
<div class="input-group-append">
<div class="input-group-text">
<i class="fa-duotone fa-user"></i>
</div>
</div>
</div>
<div class="row">
@csrf
<!-- /.col -->
<div class="col-12 justify-content-end d-flex">
<button type="submit" class="btn btn-primary">
<i class="fa-duotone fa-right-to-bracket"></i>
@lang('user.login')
</button>
</div>
<!-- /.col -->
</div>
</form>
<form action="javascript:void(0)" id="login_panel" method="post" style="display: none">
<div class="input-group mb-3">
<input type="text" name="username" class="form-control"
id="login_username"
placeholder="@lang('user.username')"
aria-label="username">
<div class="input-group-append">
<div class="input-group-text">
<i class="fa-duotone fa-user"></i>
</div>
</div>
</div>
<div class="input-group mb-3 hidden-item">
<input type="password" name="password" class="form-control"
placeholder="@lang('user.password')" aria-label="password">
<div class="input-group-append">
<div class="input-group-text">
<i class="fa-duotone fa-lock"></i>
</div>
</div>
</div>
<div class="row">
<div class="col-8">
<div class="icheck-primary">
<input type="checkbox" id="remember" name="remember">
<label for="remember">
@lang('user.remember_me')
</label>
</div>
</div>
@csrf
<!-- /.col -->
<div class="col-4 d-flex justify-content-end">
<button type="submit" class="btn btn-primary">
<i class="fa-duotone fa-right-to-bracket"></i>
@lang('user.login')
</button>
</div>
<!-- /.col -->
</div>
</form>
...
<script src="https://cdn.jsdelivr.net/npm/@laragear/webpass@2/dist/webpass.js"></script>
<script src="//cdnjs.cloudflare.com/ajax/libs/toastr.js/latest/js/toastr.min.js"></script>
<link rel="stylesheet" href="//cdnjs.cloudflare.com/ajax/libs/toastr.js/latest/css/toastr.min.css">
<script>
function notify_alert(message, type, log, reload=false) {
if(log.success){
if (type === 'success') {
toastr.success(message, 'Success!', {
closeButton: true,
tapToDismiss: false
});
if(reload){
setTimeout(function(){
location.reload();
}, 1000);
}
else{
window.location.href = '{{route('admin.index')}}';
}
} else {
toastr.error(message, 'Error!', {
closeButton: true,
tapToDismiss: false
});
}
}
else{
toastr.error(log.error.message, 'Error!', {
closeButton: true,
tapToDismiss: false
});
}
console.log(log);
}
$(document).ready(function(){
let tooltipTriggerList = [].slice.call(document.querySelectorAll('[data-bs-toggle="tooltip"]'));
tooltipTriggerList.map(function (tooltipTriggerEl) {
return new bootstrap.Tooltip(tooltipTriggerEl);
});
$('#first_step').submit(function(){
$.ajax({
url: "{{route('login.first_step')}}",
type: 'post',
data: $(this).serialize(),
success: function (result){
if(result.status && result.webauthn){
const webauthnLogin = async event => {
const webpass = Webpass.assert({
path: "{{route('webauthn.login.options')}}",
body: {
username: result.username
}
}, "{{route('webauthn.login')}}")
.then(response => notify_alert('{{__('webauthn.verification_success')}}', 'success', response))
.catch(error => notify_alert('{{__('webauthn.verification_failed')}}', 'error', error))
}
webauthnLogin();
}
else{
$('#first_step').hide();
$('#login_username').val($('#username').val());
$('#login_panel').show();
$('.hidden-item').show();
}
},
error: function(xhr){
console.log(xhr);
$('#first_step').trigger("reset");
$("#username").focus();
Swal.fire({
icon: 'warning',
title: '@lang('user.login_request.error_title')',
text: xhr.responseJSON.message,
showConfirmButton: false,
});
}
});
});
$('#login_panel').submit(function(){
$.ajax({
url: "{{route('login')}}",
type: 'post',
data: $(this).serialize(),
success: function (result) {
if(result.status){
if(result.webauthn){
const webauthnLogin = async event => {
const webpass = Webpass.assert({
path: "{{route('webauthn.login.options')}}",
body: {
username: result.username
}
}, "{{route('webauthn.login')}}")
.then(response => notify_alert('{{__('webauthn.verification_success')}}', 'success', response))
.catch(error => notify_alert('{{__('webauthn.verification_failed')}}', 'error', error))
}
webauthnLogin();
}
else{
Swal.fire({
icon: 'success',
title: '@lang('user.login_request.success_title')',
text: '@lang('user.login_request.success')',
showConfirmButton: false,
timer: 1500
});
setTimeout(function(){
window.location.href = '{{route('admin.index')}}';
}, 1000);
}
}
else{
Swal.fire({
icon: 'warning',
title: '@lang('user.login_request.error_title')',
text: '@lang('user.login_request.error')',
showConfirmButton: false,
});
}
},
error: function (xhr) {
console.log(xhr);
$('#login_panel').trigger("reset");
$("#username").focus();
Swal.fire({
icon: 'warning',
title: '@lang('user.login_request.error_title')',
text: xhr.responseJSON.message,
showConfirmButton: false,
});
}
});
})
});
</script>
When we apply all these steps, we can register a security device for WebAuthn on our site and then log in with it. The most important part of all these processes is correctly configuring the JavaScript side. In summary, you can register your security device with the following JavaScript code. The Webpass documentation does not mention the importance of the body part, but if this part is missing, the device is registered, but the system cannot see the device during the login process.
const attest = async () => await Webpass.attest(
{
path: "{{route('webauthn.register.options')}}",
body: {
username: '{{auth()->user()->username}}',
}
}, "{{route('webauthn.register')}}"
)
.then(response => notify_alert('{{__('webauthn.verification_success')}}', 'success', response))
.catch(error => notify_alert('{{__('webauthn.verification_failed')}}', 'error', error));
document.getElementById('register-form').addEventListener('submit', attest);
Login
const webauthnLogin = async event => {
const webpass = Webpass.assert({
path: "{{route('webauthn.login.options')}}",
body: {
username: result.username
}
}, "{{route('webauthn.login')}}")
.then(response => notify_alert('{{__('webauthn.verification_success')}}', 'success', response))
.catch(error => notify_alert('{{__('webauthn.verification_failed')}}', 'error', error))
}
webauthnLogin();
There are none comment