M. Niyazi Alpay
M. Niyazi Alpay
M. Niyazi Alpay

I've been interested in computer systems since a very young age, and I've been programming since 2005. I have knowledge in PHP, MySQL, Python, MongoDB, and Linux.

 

about.me/Cryptograph

  • admin@niyazi.org
Laravel and WebAuthn

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

After installation, we change the login driver in config/auth.php.

return [
    // ...

    'providers' => [
        'users' => [
            'driver' => 'eloquent-webauthn',
            'model' => App\User::class,
            'password_fallback' => true,
        ],
    ]
];

After then, we run the following commands to create the necessary table.

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']);
    }
}

We can move on to defining routes, controllers, and views. We also need to make the necessary JavaScript definitions for WebAuthn on the view side.

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();
This post has a version in a different language.
Türkçe: https://niyazi.net/tr/laravel-ve-webauthn

Muhammed Niyazi ALPAY - Cryptograph

Senior Software Developer & Senior Linux System Administrator

Meraklı

PHP MySQL MongoDB Python Linux Cyber Security

You may also want to read these

There are none comment

Leave a comment

Your email address will not be published. Required fields are marked *