![Laravel ve WebAuthn](https://cdn.niyazi.net/image/143/800/400/resized/webauthn-web-authenticaiton.jpg)
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();
English: https://niyazi.net/en/laravel-and-webauthn
Hiç yorum yok