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

Çok küçük yaştan itibaren bilgisayar sistemleriyle ilgileniyorum ve 2005 yılından beri programlama ile uğraşıyorum, PHP, MySQL, Python, MongoDB ve Linux konularında bilgi sahibiyim

 

about.me/Cryptograph

Laravel ve WebAuthn

Daha önceki yazımda WebAuthn hakkında bilgi vermiştim. Bu yazımda Laravel ile nasıl yapılacağını anlatmaya çalışacağım.

Öncelikle laragear/webauthn paketini projemize dahil ediyoruz.

composer require laragear/webauthn

Kurulum sonrasında config/auth.php içerisinde login drıverını değiştiriyoruz.

return [
    // ...

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

Hemen ardından aşağıdaki komutları çalıştırarak gerekli tablonun oluşturulmasını sağlıyoruz.

php artisan webauthn:install
php artisan migrate

Burada veritabanında webauthn_credentials isimli bir tablo oluşacak, bu tabloya bir de device_name eklemek için yeni bir migration oluşturalım

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

Bu işlemlerden sonra User modelimizi aşağıdaki gibi güncelliyoruz.

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

Route, controller ve view tanımlamalarına geçebiliriz. View tarafında WebAuthn için gerekli javascript tanımlamalarını da yapmamız gerekiyor.

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>

Burada butona tıkladıktan sonra sayfaya yüklediğimiz webpass.js aracılığı ile tarayıcı tarafındaki WebAuthn fonksiyonları devreye giriyor ve bizden cihaz kaydetmemizi istiyor. Burada ben sistemi kullanıcı adına göre tasarladım, Laravel varsayılanında email adresine göre login kontrolleri yapılıyor. Eğer email ile yapmak isterseniz js tarafındaki  body: {  username: '{{auth()->user()->username}}',} alanını email ile değiştirebilirsiniz.

 

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>

 

Tüm bunları uyguladığımızda WebAuthn için sitemize bir güvenlik cihazı kaydı yapıp sonrasında da onunla siteye login olabiliriz. Tüm işlemler arasında en önemli olan kısmı javascript tarafını doğru ayarlamak. Özet olarak aşağıdaki javascript kodu ile güvenlik cihazınızı kaydedebilirsiniz. Webpass dökümanlarında body kısmının öneminden bahsedilmemiş ancak bu kısım eksik olduğunda cihaz kaydı yapılıyor ama login işlemi sırasında sistem cihazı göremiyor. 

            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();
Bu yazının farklı bir dilde versiyonu bulunmaktadır.
English: https://niyazi.net/en/laravel-and-webauthn

Bunları da okumak isteyebilirsiniz

Hiç yorum yok

Yorum Bırakın

E-posta adresiniz yayınlanmayacaktır. Zorunlu alanlar * ile işaretlenmiştir