Fix missing login throttling on WebAuthn login controller

This commit is contained in:
Bubka 2023-03-16 13:23:58 +01:00
parent 82717077be
commit 960d1ca5f9
5 changed files with 320 additions and 70 deletions

View File

@ -3,19 +3,23 @@
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use App\Http\Requests\WebauthnAssertedRequest;
use App\Models\User;
use Carbon\Carbon;
use Illuminate\Contracts\Support\Responsable;
use Illuminate\Foundation\Auth\AuthenticatesUsers;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Response;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Lang;
use Illuminate\Support\Facades\Log;
use Laragear\WebAuthn\Http\Requests\AssertedRequest;
use Laragear\WebAuthn\Http\Requests\AssertionRequest;
use Laragear\WebAuthn\WebAuthn;
class WebAuthnLoginController extends Controller
{
use AuthenticatesUsers;
/*
|--------------------------------------------------------------------------
| WebAuthn Login Controller
@ -56,12 +60,28 @@ public function options(AssertionRequest $request) : Responsable|JsonResponse
/**
* Log the user in.
*
* @param \Laragear\WebAuthn\Http\Requests\AssertedRequest $request
* @param \App\Http\Requests\WebauthnAssertedRequest $request
* @return \Illuminate\Http\Response|\Illuminate\Http\JsonResponse
*/
public function login(AssertedRequest $request)
public function login(WebauthnAssertedRequest $request)
{
Log::info('User login via webauthn requested');
Log::info(sprintf('User login via webauthn requested by %s from %s', var_export($request['email'], true), $request->ip()));
// If the class is using the ThrottlesLogins trait, we can automatically throttle
// the login attempts for this application. We'll key this by the username and
// the IP address of the client making these requests into this application.
if (method_exists($this, 'hasTooManyLoginAttempts') &&
$this->hasTooManyLoginAttempts($request)) {
$this->fireLockoutEvent($request);
Log::notice(sprintf(
'%s from %s locked-out, too many failed login attempts (using webauthn)',
var_export($request['email'], true),
$request->ip()
));
return $this->sendLockoutResponse($request);
}
if ($request->has('response')) {
$response = $request->response;
@ -74,22 +94,110 @@ public function login(AssertedRequest $request)
}
}
if ($this->attemptLogin($request)) {
return $this->sendLoginResponse($request);
}
// If the login attempt was unsuccessful we will increment the number of attempts
// to login and redirect the user back to the login form. Of course, when this
// user surpasses their maximum number of attempts they will get locked out.
$this->incrementLoginAttempts($request);
Log::notice(sprintf(
'Failed login for %s from %s - Attemp %d/%d (using webauthn)',
var_export($request['email'], true),
$request->ip(),
$this->limiter()->attempts($this->throttleKey($request)),
$this->maxAttempts()
));
return response()->json(['message' => 'unauthorized'], Response::HTTP_UNAUTHORIZED);
}
/**
* Attempt to log the user into the application.
*
* @param \App\Http\Requests\WebauthnAssertedRequest $request
* @return bool
*/
protected function attemptLogin(WebauthnAssertedRequest $request)
{
return ! is_null($request->login());
}
/**
* Send the response after the user was authenticated.
*
* @param \App\Http\Requests\WebauthnAssertedRequest $request
* @return \Illuminate\Http\JsonResponse
*/
protected function sendLoginResponse(WebauthnAssertedRequest $request)
{
$this->clearLoginAttempts($request);
/**
* @var \App\Models\User|null
*/
$user = $request->login();
$user = $this->guard()->user();
if ($user) {
$this->authenticated($user);
$this->authenticated($user);
return response()->json([
'message' => 'authenticated',
'name' => $user->name,
'preferences' => $user->preferences,
], Response::HTTP_OK);
}
return response()->json([
'message' => 'authenticated',
'name' => $user->name,
'preferences' => $user->preferences,
], Response::HTTP_OK);
}
return response()->noContent(422);
/**
* Get the failed login response instance.
*
* @param \App\Http\Requests\WebauthnAssertedRequest $request
* @return \Illuminate\Http\JsonResponse
*/
protected function sendFailedLoginResponse(WebauthnAssertedRequest $request)
{
return response()->json(['message' => 'unauthorized'], Response::HTTP_UNAUTHORIZED);
}
/**
* Redirect the user after determining they are locked out.
*
* @param \App\Http\Requests\WebauthnAssertedRequest $request
* @return \Illuminate\Http\JsonResponse
*/
protected function sendLockoutResponse(WebauthnAssertedRequest $request)
{
$seconds = $this->limiter()->availableIn(
$this->throttleKey($request)
);
return response()->json(['message' => Lang::get('auth.throttle', ['seconds' => $seconds])], Response::HTTP_TOO_MANY_REQUESTS);
}
/**
* Get the login username to be used by the controller.
*
* @return string
*/
public function username()
{
return 'email';
}
/**
* Get the needed authorization credentials from the request.
*
* @param \App\Http\Requests\WebauthnAssertedRequest $request
* @return array
*/
protected function credentials(WebauthnAssertedRequest $request)
{
$credentials = [
$this->username() => strtolower($request->input($this->username())),
];
return $credentials;
}
/**
@ -103,6 +211,6 @@ protected function authenticated($user)
$user->last_seen_at = Carbon::now()->format('Y-m-d H:i:s');
$user->save();
Log::info(sprintf('User ID #%s authenticated using webauthn', $user->id));
Log::info(sprintf('User ID #%s authenticated (using webauthn)', $user->id));
}
}

View File

@ -0,0 +1,23 @@
<?php
namespace App\Http\Requests;
use Laragear\WebAuthn\Http\Requests\AssertedRequest;
class WebauthnAssertedRequest extends AssertedRequest
{
/**
* Get the validation rules that apply to the request.
*
* @return array
*/
public function rules(): array
{
return array_merge(
[
'email' => 'required|email',
],
parent::rules()
);
}
}

View File

@ -138,7 +138,8 @@
if (!credentials) return false
const publicKeyCredential = this.webauthn.parseOutgoingCredentials(credentials)
let publicKeyCredential = this.webauthn.parseOutgoingCredentials(credentials)
publicKeyCredential.email = this.form.email
this.axios.post('/webauthn/login', publicKeyCredential, {returnError: true}).then(response => {
this.applyPreferences(response.data.preferences);

View File

@ -44,9 +44,14 @@ public function test_user_login_returns_success()
'password' => self::PASSWORD,
])
->assertOk()
->assertExactJson([
->assertJsonFragment([
'message' => 'authenticated',
'name' => $this->user->name,
])
->assertJsonStructure([
'message',
'name',
'preferences',
]);
}
@ -62,9 +67,14 @@ public function test_user_login_with_uppercased_email_returns_success()
'password' => self::PASSWORD,
])
->assertOk()
->assertExactJson([
->assertJsonFragment([
'message' => 'authenticated',
'name' => $this->user->name,
])
->assertJsonStructure([
'message',
'name',
'preferences',
]);
}
@ -73,7 +83,7 @@ public function test_user_login_with_uppercased_email_returns_success()
*
* @covers \App\Http\Middleware\SkipIfAuthenticated
*/
public function test_user_login_already_authenticated_returns_bad_request()
public function test_user_login_already_authenticated_returns_success()
{
$response = $this->json('POST', '/user/login', [
'email' => $this->user->email,
@ -113,7 +123,7 @@ public function test_user_login_with_missing_data_returns_validation_error()
*
* @covers \App\Exceptions\Handler
*/
public function test_user_login_with_invalid_credentials_returns_authentication_error()
public function test_user_login_with_invalid_credentials_returns_unauthorized()
{
$response = $this->json('POST', '/user/login', [
'email' => $this->user->email,
@ -121,7 +131,7 @@ public function test_user_login_with_invalid_credentials_returns_authentication_
])
->assertStatus(401)
->assertJson([
'message' => 'unauthorised',
'message' => 'unauthorized',
]);
}
@ -130,35 +140,17 @@ public function test_user_login_with_invalid_credentials_returns_authentication_
*/
public function test_too_many_login_attempts_with_invalid_credentials_returns_too_many_request_error()
{
$response = $this->json('POST', '/user/login', [
$post = [
'email' => $this->user->email,
'password' => self::WRONG_PASSWORD,
]);
];
$response = $this->json('POST', '/user/login', [
'email' => $this->user->email,
'password' => self::WRONG_PASSWORD,
]);
$response = $this->json('POST', '/user/login', [
'email' => $this->user->email,
'password' => self::WRONG_PASSWORD,
]);
$response = $this->json('POST', '/user/login', [
'email' => $this->user->email,
'password' => self::WRONG_PASSWORD,
]);
$response = $this->json('POST', '/user/login', [
'email' => $this->user->email,
'password' => self::WRONG_PASSWORD,
]);
$response = $this->json('POST', '/user/login', [
'email' => $this->user->email,
'password' => self::WRONG_PASSWORD,
]);
$this->json('POST', '/user/login', $post);
$this->json('POST', '/user/login', $post);
$this->json('POST', '/user/login', $post);
$this->json('POST', '/user/login', $post);
$this->json('POST', '/user/login', $post);
$response = $this->json('POST', '/user/login', $post);
$response->assertStatus(429);
}

View File

@ -6,7 +6,6 @@
use Illuminate\Support\Facades\Config;
use Illuminate\Support\Facades\DB;
use Laragear\WebAuthn\Assertion\Validator\AssertionValidator;
use Laragear\WebAuthn\Http\Requests\AssertedRequest;
use Laragear\WebAuthn\WebAuthn;
use Tests\FeatureTestCase;
@ -33,6 +32,8 @@ class WebAuthnLoginControllerTest extends FeatureTestCase
const USER_ID_ALT = 'e8af6f703f8042aa91c30cf72289aa07';
const EMAIL = 'john.doe@example.com';
const ASSERTION_RESPONSE = [
'id' => self::CREDENTIAL_ID_ALT,
'rawId' => self::CREDENTIAL_ID_ALT_RAW,
@ -43,6 +44,7 @@ class WebAuthnLoginControllerTest extends FeatureTestCase
'signature' => 'ca4IJ9h8bZnjMbEFuHX1zfX5LcbiPyDVz6sD1/ppR4t8++1DxKa5EdBIrfNlo8FSOv/JSzMrGGUCQvc/Ngj1KnZpO3s9OdTb54/gMDewH/K8EG4wSvxzHdL6sMbP7UUc5Wq1pcdu9MgXY8V+1gftXpzcoaae0X+mLEETgU7eB8jG0mZhVWvE4yQKuDnZA1i9r8oQhqsvG4nUw1BxvR8wAGiRR+R287LaL41k+xum5mS8zEojUmuLSH50miyVxZ4Y+/oyfxG7i+wSYGNSXlW5iNPB+2WupGS7ce4TuOgaFeMmP2a9rzP4m2IBSQoJ2FyrdzR7HwBEewqqrUVbGQw3Aw==',
'userHandle' => self::USER_ID_ALT,
],
'email' => self::EMAIL,
];
const ASSERTION_RESPONSE_NO_HANDLE = [
@ -55,6 +57,20 @@ class WebAuthnLoginControllerTest extends FeatureTestCase
'signature' => 'ca4IJ9h8bZnjMbEFuHX1zfX5LcbiPyDVz6sD1/ppR4t8++1DxKa5EdBIrfNlo8FSOv/JSzMrGGUCQvc/Ngj1KnZpO3s9OdTb54/gMDewH/K8EG4wSvxzHdL6sMbP7UUc5Wq1pcdu9MgXY8V+1gftXpzcoaae0X+mLEETgU7eB8jG0mZhVWvE4yQKuDnZA1i9r8oQhqsvG4nUw1BxvR8wAGiRR+R287LaL41k+xum5mS8zEojUmuLSH50miyVxZ4Y+/oyfxG7i+wSYGNSXlW5iNPB+2WupGS7ce4TuOgaFeMmP2a9rzP4m2IBSQoJ2FyrdzR7HwBEewqqrUVbGQw3Aw==',
'userHandle' => null,
],
'email' => self::EMAIL,
];
const ASSERTION_RESPONSE_INVALID = [
'id' => self::CREDENTIAL_ID_ALT,
'rawId' => self::CREDENTIAL_ID_ALT_RAW,
'type' => 'public-key',
'response' => [
'clientDataJSON' => 'eyJ0eXBlIjoid2ViYXV0aG4uZ2V0IiwiY2hhbGxlbmdlIjoiaVhvem15bktpLVlEMmlSdktOYlNQQSIsIm9yaWdpbiI6Imh0dHA6Ly9sb2NhbGhvc3QiLCJjcm9zc09yaWdpbiI6ZmFsc2V9',
'authenticatorData' => 'SZYN5YgOjGh0NBcPZHZgW4/krrmihjLHmVzzuoMdl2MFAAAAAQ==',
'signature' => 'ca4IJ9h8bZnjMbEFuHX1zfX5LcbiPyDVz6sD1/ppR4t8++1DxKa5EdBIrfNlo8FSOv/JSzMrGGUCQvc/Ngj1KnZpO3s9OdTb54/gMDewH/K8EG4wSvxzHdL6sMbP7UUc5Wq1pcdu9MgXY8V+1gftXpzcoaae0X+mLEETgU7eB8jG0mZhVWvE4yQKuDnZA1i9r8oQhqsvG4nUw1BxvR8wAGiRR+R287LaL41k+xum5mS8zEojUmuLSH50miyVxZ4Y+/oyfxG7i+wSYGNSXlW5iNPB+2WupGS7ce4TuOgaFeMmP2a9rzP4m2IBSQoJ2FyrdzR7HwBEewqqrUVbGQw3Aw==',
'userHandle' => self::USER_ID_ALT,
],
'email' => self::EMAIL,
];
const ASSERTION_CHALLENGE = 'iXozmynKi+YD2iRvKNbSPA==';
@ -72,18 +88,46 @@ public function setUp() : void
/**
* @test
*/
public function test_webauthn_login_uses_login_and_returns_no_content()
public function test_webauthn_login_returns_success()
{
$this->user = User::factory()->create();
$this->user = User::factory()->create(['email' => self::EMAIL]);
$mock = $this->mock(AssertedRequest::class)->makePartial()->shouldIgnoreMissing();
$mock->shouldReceive([
'has' => false,
'login' => $this->user,
DB::table('webauthn_credentials')->insert([
'id' => self::CREDENTIAL_ID_ALT,
'authenticatable_type' => \App\Models\User::class,
'authenticatable_id' => $this->user->id,
'user_id' => self::USER_ID_ALT,
'counter' => 0,
'rp_id' => 'http://localhost',
'origin' => 'http://localhost',
'aaguid' => '00000000-0000-0000-0000-000000000000',
'attestation_format' => 'none',
'public_key' => self::PUBLIC_KEY,
'updated_at' => now(),
'created_at' => now(),
]);
$this->json('POST', '/webauthn/login')
->assertNoContent();
$this->session(['_webauthn' => new \Laragear\WebAuthn\Challenge(
new \Laragear\WebAuthn\ByteBuffer(base64_decode(self::ASSERTION_CHALLENGE)),
60,
false,
)]);
$this->mock(AssertionValidator::class)
->expects('send->thenReturn')
->andReturn();
$this->json('POST', '/webauthn/login', self::ASSERTION_RESPONSE)
->assertOk()
->assertJsonFragment([
'message' => 'authenticated',
'name' => $this->user->name,
])
->assertJsonStructure([
'message',
'name',
'preferences',
]);
}
/**
@ -91,7 +135,7 @@ public function test_webauthn_login_uses_login_and_returns_no_content()
*/
public function test_webauthn_login_merge_handle_if_missing()
{
$this->user = User::factory()->create();
$this->user = User::factory()->create(['email' => self::EMAIL]);
DB::table('webauthn_credentials')->insert([
'id' => self::CREDENTIAL_ID_ALT,
@ -119,24 +163,66 @@ public function test_webauthn_login_merge_handle_if_missing()
->andReturn();
$this->json('POST', '/webauthn/login', self::ASSERTION_RESPONSE_NO_HANDLE)
->assertNoContent();
->assertOk()
->assertJsonFragment([
'message' => 'authenticated',
'name' => $this->user->name,
])
->assertJsonStructure([
'message',
'name',
'preferences',
]);
}
/**
* @test
*
* @covers \App\Http\Middleware\SkipIfAuthenticated
*/
public function test_webauthn_invalid_login_returns_error()
public function test_webauthn_login_already_authenticated_returns_success()
{
$this->user = User::factory()->create();
$this->user = User::factory()->create(['email' => self::EMAIL]);
$mock = $this->mock(AssertedRequest::class)->makePartial()->shouldIgnoreMissing();
$mock->shouldReceive([
'has' => false,
'login' => null,
DB::table('webauthn_credentials')->insert([
'id' => self::CREDENTIAL_ID_ALT,
'authenticatable_type' => \App\Models\User::class,
'authenticatable_id' => $this->user->id,
'user_id' => self::USER_ID_ALT,
'counter' => 0,
'rp_id' => 'http://localhost',
'origin' => 'http://localhost',
'aaguid' => '00000000-0000-0000-0000-000000000000',
'attestation_format' => 'none',
'public_key' => self::PUBLIC_KEY,
'updated_at' => now(),
'created_at' => now(),
]);
$this->json('POST', '/webauthn/login')
->assertNoContent(422);
$this->session(['_webauthn' => new \Laragear\WebAuthn\Challenge(
new \Laragear\WebAuthn\ByteBuffer(base64_decode(self::ASSERTION_CHALLENGE)),
60,
false,
)]);
$this->mock(AssertionValidator::class)
->expects('send->thenReturn')
->andReturn();
$this->json('POST', '/webauthn/login', self::ASSERTION_RESPONSE)
->assertOk();
$this->json('POST', '/webauthn/login', self::ASSERTION_RESPONSE)
->assertOk()
->assertJsonFragment([
'message' => 'authenticated',
'name' => $this->user->name,
])
->assertJsonStructure([
'message',
'name',
'preferences',
]);
}
/**
@ -144,7 +230,7 @@ public function test_webauthn_invalid_login_returns_error()
*/
public function test_webauthn_login_with_missing_data_returns_validation_error()
{
$this->user = User::factory()->create();
$this->user = User::factory()->create(['email' => self::EMAIL]);
$data = [
'id' => '',
@ -170,6 +256,46 @@ public function test_webauthn_login_with_missing_data_returns_validation_error()
]);
}
/**
* @test
*/
public function test_webauthn_invalid_login_returns_unauthorized()
{
$this->user = User::factory()->create(['email' => self::EMAIL]);
$this->session(['_webauthn' => new \Laragear\WebAuthn\Challenge(
new \Laragear\WebAuthn\ByteBuffer(base64_decode(self::ASSERTION_CHALLENGE)),
60,
false,
)]);
$this->json('POST', '/webauthn/login', self::ASSERTION_RESPONSE_INVALID)
->assertUnauthorized();
}
/**
* @test
*/
public function test_too_many_invalid_login_attempts_returns_too_many_request_error()
{
$this->user = User::factory()->create(['email' => self::EMAIL]);
$this->session(['_webauthn' => new \Laragear\WebAuthn\Challenge(
new \Laragear\WebAuthn\ByteBuffer(base64_decode(self::ASSERTION_CHALLENGE)),
60,
false,
)]);
$this->json('POST', '/webauthn/login', self::ASSERTION_RESPONSE_INVALID);
$this->json('POST', '/webauthn/login', self::ASSERTION_RESPONSE_INVALID);
$this->json('POST', '/webauthn/login', self::ASSERTION_RESPONSE_INVALID);
$this->json('POST', '/webauthn/login', self::ASSERTION_RESPONSE_INVALID);
$this->json('POST', '/webauthn/login', self::ASSERTION_RESPONSE_INVALID);
$response = $this->json('POST', '/webauthn/login', self::ASSERTION_RESPONSE_INVALID);
$response->assertStatus(429);
}
/**
* @test
*/
@ -177,7 +303,7 @@ public function test_get_options_for_securelogin_returns_success()
{
Config::set('webauthn.user_verification', WebAuthn::USER_VERIFICATION_REQUIRED);
$this->user = User::factory()->create();
$this->user = User::factory()->create(['email' => self::EMAIL]);
DB::table('webauthn_credentials')->insert([
'id' => self::CREDENTIAL_ID,
@ -219,7 +345,7 @@ public function test_get_options_for_fastlogin_returns_success()
{
Config::set('webauthn.user_verification', WebAuthn::USER_VERIFICATION_DISCOURAGED);
$this->user = User::factory()->create();
$this->user = User::factory()->create(['email' => self::EMAIL]);
DB::table('webauthn_credentials')->insert([
'id' => self::CREDENTIAL_ID,
@ -259,7 +385,7 @@ public function test_get_options_for_fastlogin_returns_success()
*/
public function test_get_options_with_capitalized_email_returns_success()
{
$this->user = User::factory()->create();
$this->user = User::factory()->create(['email' => self::EMAIL]);
$this->json('POST', '/webauthn/login/options', [
'email' => strtoupper($this->user->email),