First round of refinements on the login system...
There's a lot more to do on the to-do list
This commit is contained in:
parent
82ed2e3ce2
commit
1b241aeddb
7 changed files with 390 additions and 219 deletions
|
|
@ -19,7 +19,7 @@ class MagicLinkAuthTest extends TestCase
|
|||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
RateLimiter::clear('magic-link:' . hash('sha256', strtolower('test@example.com')));
|
||||
RateLimiter::clear('magic-link:' . User::hashEmail('test@example.com'));
|
||||
RateLimiter::clear('magic-code:' . User::hashEmail('test@example.com'));
|
||||
}
|
||||
|
||||
|
|
@ -77,12 +77,12 @@ class MagicLinkAuthTest extends TestCase
|
|||
'email_hash' => User::hashEmail('test@example.com'),
|
||||
]);
|
||||
|
||||
$token = MagicLoginToken::generate('test@example.com', '127.0.0.1', 'TestAgent');
|
||||
$result = MagicLoginToken::generate('test@example.com', '127.0.0.1', 'TestAgent');
|
||||
|
||||
$signedUrl = URL::temporarySignedRoute(
|
||||
'magic-link.verify',
|
||||
now()->addMinutes(15),
|
||||
['token' => $token->plain_token]
|
||||
['token' => $result->plainToken]
|
||||
);
|
||||
|
||||
$this->assertGuest();
|
||||
|
|
@ -93,8 +93,8 @@ class MagicLinkAuthTest extends TestCase
|
|||
$this->assertAuthenticated();
|
||||
$this->assertEquals($user->id, Auth::id());
|
||||
|
||||
$token->refresh();
|
||||
$this->assertNotNull($token->used_at);
|
||||
$result->token->refresh();
|
||||
$this->assertNotNull($result->token->used_at);
|
||||
}
|
||||
|
||||
public function test_valid_6_digit_code_logs_user_in(): void
|
||||
|
|
@ -103,22 +103,21 @@ class MagicLinkAuthTest extends TestCase
|
|||
'email_hash' => User::hashEmail('test@example.com'),
|
||||
]);
|
||||
|
||||
$token = MagicLoginToken::generate('test@example.com', '127.0.0.1', 'TestAgent');
|
||||
$code = $token->plain_code;
|
||||
$result = MagicLoginToken::generate('test@example.com', '127.0.0.1', 'TestAgent');
|
||||
|
||||
$this->assertGuest();
|
||||
|
||||
$response = $this->post('/verify-code', [
|
||||
'email' => 'test@example.com',
|
||||
'code' => $code,
|
||||
'code' => $result->plainCode,
|
||||
]);
|
||||
|
||||
$response->assertRedirect(route('dashboard'));
|
||||
$this->assertAuthenticated();
|
||||
$this->assertEquals($user->id, Auth::id());
|
||||
|
||||
$token->refresh();
|
||||
$this->assertNotNull($token->used_at);
|
||||
$result->token->refresh();
|
||||
$this->assertNotNull($result->token->used_at);
|
||||
}
|
||||
|
||||
public function test_invalid_code_rejected_with_error(): void
|
||||
|
|
@ -127,11 +126,14 @@ class MagicLinkAuthTest extends TestCase
|
|||
'email_hash' => User::hashEmail('test@example.com'),
|
||||
]);
|
||||
|
||||
MagicLoginToken::generate('test@example.com', '127.0.0.1', 'TestAgent');
|
||||
$result = MagicLoginToken::generate('test@example.com', '127.0.0.1', 'TestAgent');
|
||||
|
||||
// Derive a code guaranteed to differ from the real one
|
||||
$wrongCode = str_pad((string) (((int) $result->plainCode + 1) % 1000000), 6, '0', STR_PAD_LEFT);
|
||||
|
||||
$response = $this->post('/verify-code', [
|
||||
'email' => 'test@example.com',
|
||||
'code' => '999999',
|
||||
'code' => $wrongCode,
|
||||
]);
|
||||
|
||||
$response->assertRedirect();
|
||||
|
|
@ -141,18 +143,18 @@ class MagicLinkAuthTest extends TestCase
|
|||
|
||||
public function test_expired_token_rejected(): void
|
||||
{
|
||||
$user = User::create([
|
||||
User::create([
|
||||
'email_hash' => User::hashEmail('test@example.com'),
|
||||
]);
|
||||
|
||||
$token = MagicLoginToken::generate('test@example.com', '127.0.0.1', 'TestAgent');
|
||||
$result = MagicLoginToken::generate('test@example.com', '127.0.0.1', 'TestAgent');
|
||||
|
||||
$token->update(['expires_at' => now()->subMinutes(1)]);
|
||||
$result->token->update(['expires_at' => now()->subMinutes(1)]);
|
||||
|
||||
$signedUrl = URL::temporarySignedRoute(
|
||||
'magic-link.verify',
|
||||
now()->addMinutes(15),
|
||||
['token' => $token->plain_token]
|
||||
['token' => $result->plainToken]
|
||||
);
|
||||
|
||||
$this->assertGuest();
|
||||
|
|
@ -170,12 +172,12 @@ class MagicLinkAuthTest extends TestCase
|
|||
'email_hash' => User::hashEmail('test@example.com'),
|
||||
]);
|
||||
|
||||
$token = MagicLoginToken::generate('test@example.com', '127.0.0.1', 'TestAgent');
|
||||
$result = MagicLoginToken::generate('test@example.com', '127.0.0.1', 'TestAgent');
|
||||
|
||||
$signedUrl = URL::temporarySignedRoute(
|
||||
'magic-link.verify',
|
||||
now()->addMinutes(15),
|
||||
['token' => $token->plain_token]
|
||||
['token' => $result->plainToken]
|
||||
);
|
||||
|
||||
$this->get($signedUrl);
|
||||
|
|
@ -221,33 +223,40 @@ class MagicLinkAuthTest extends TestCase
|
|||
$errors->first('email')
|
||||
);
|
||||
|
||||
RateLimiter::clear('magic-link:' . hash('sha256', strtolower($email)));
|
||||
RateLimiter::clear('magic-link:' . User::hashEmail($email));
|
||||
}
|
||||
|
||||
public function test_rate_limiting_on_code_attempts(): void
|
||||
{
|
||||
$user = User::create([
|
||||
User::create([
|
||||
'email_hash' => User::hashEmail('test@example.com'),
|
||||
]);
|
||||
|
||||
MagicLoginToken::generate('test@example.com', '127.0.0.1', 'TestAgent');
|
||||
$first = MagicLoginToken::generate('test@example.com', '127.0.0.1', 'TestAgent');
|
||||
|
||||
// Derive a code guaranteed to be wrong so the loop never accidentally succeeds
|
||||
$wrongCode = str_pad((string) (((int) $first->plainCode + 1) % 1000000), 6, '0', STR_PAD_LEFT);
|
||||
|
||||
for ($i = 0; $i < 5; $i++) {
|
||||
$this->post('/verify-code', [
|
||||
'email' => 'test@example.com',
|
||||
'code' => '000000',
|
||||
'code' => $wrongCode,
|
||||
]);
|
||||
}
|
||||
|
||||
$token = MagicLoginToken::generate('test@example.com', '127.0.0.1', 'TestAgent');
|
||||
$validCode = $token->plain_code;
|
||||
// Even a fresh valid token is blocked once the limit is reached
|
||||
$result = MagicLoginToken::generate('test@example.com', '127.0.0.1', 'TestAgent');
|
||||
|
||||
$response = $this->post('/verify-code', [
|
||||
'email' => 'test@example.com',
|
||||
'code' => $validCode,
|
||||
'code' => $result->plainCode,
|
||||
]);
|
||||
|
||||
$this->assertGuest();
|
||||
$response->assertSessionHasErrors('code');
|
||||
|
||||
$errors = session('errors');
|
||||
$this->assertStringContainsString('Too many attempts', $errors->first('code'));
|
||||
|
||||
RateLimiter::clear('magic-code:' . User::hashEmail('test@example.com'));
|
||||
}
|
||||
|
|
@ -262,13 +271,13 @@ class MagicLinkAuthTest extends TestCase
|
|||
|
||||
public function test_invalid_signature_rejected(): void
|
||||
{
|
||||
$user = User::create([
|
||||
User::create([
|
||||
'email_hash' => User::hashEmail('test@example.com'),
|
||||
]);
|
||||
|
||||
$token = MagicLoginToken::generate('test@example.com', '127.0.0.1', 'TestAgent');
|
||||
$result = MagicLoginToken::generate('test@example.com', '127.0.0.1', 'TestAgent');
|
||||
|
||||
$invalidUrl = route('magic-link.verify', ['token' => $token->plain_token]);
|
||||
$invalidUrl = route('magic-link.verify', ['token' => $result->plainToken]);
|
||||
|
||||
$response = $this->get($invalidUrl);
|
||||
|
||||
|
|
@ -279,16 +288,16 @@ class MagicLinkAuthTest extends TestCase
|
|||
|
||||
public function test_used_token_cannot_be_reused(): void
|
||||
{
|
||||
$user = User::create([
|
||||
User::create([
|
||||
'email_hash' => User::hashEmail('test@example.com'),
|
||||
]);
|
||||
|
||||
$token = MagicLoginToken::generate('test@example.com', '127.0.0.1', 'TestAgent');
|
||||
$result = MagicLoginToken::generate('test@example.com', '127.0.0.1', 'TestAgent');
|
||||
|
||||
$signedUrl = URL::temporarySignedRoute(
|
||||
'magic-link.verify',
|
||||
now()->addMinutes(15),
|
||||
['token' => $token->plain_token]
|
||||
['token' => $result->plainToken]
|
||||
);
|
||||
|
||||
$this->get($signedUrl);
|
||||
|
|
@ -347,4 +356,103 @@ class MagicLinkAuthTest extends TestCase
|
|||
|
||||
$response->assertSessionHasErrors('code');
|
||||
}
|
||||
|
||||
public function test_plain_values_not_stored_after_magic_link_request(): void
|
||||
{
|
||||
Mail::fake();
|
||||
|
||||
$this->post('/magic-link', ['email' => 'test@example.com']);
|
||||
|
||||
$token = MagicLoginToken::where('email_hash', User::hashEmail('test@example.com'))->firstOrFail();
|
||||
|
||||
$this->assertArrayNotHasKey('plain_token', $token->getAttributes());
|
||||
$this->assertArrayNotHasKey('plain_code', $token->getAttributes());
|
||||
|
||||
// Confirm the hash is the correct length for SHA256
|
||||
$this->assertEquals(64, strlen($token->token_hash));
|
||||
}
|
||||
|
||||
public function test_email_verified_on_first_login_via_link(): void
|
||||
{
|
||||
$user = User::create([
|
||||
'email_hash' => User::hashEmail('test@example.com'),
|
||||
]);
|
||||
|
||||
$this->assertNull($user->email_verified_at);
|
||||
|
||||
$result = MagicLoginToken::generate('test@example.com', '127.0.0.1', 'TestAgent');
|
||||
|
||||
$signedUrl = URL::temporarySignedRoute(
|
||||
'magic-link.verify',
|
||||
now()->addMinutes(15),
|
||||
['token' => $result->plainToken]
|
||||
);
|
||||
|
||||
$this->get($signedUrl);
|
||||
|
||||
$user->refresh();
|
||||
$this->assertNotNull($user->email_verified_at);
|
||||
}
|
||||
|
||||
public function test_email_verified_on_first_login_via_code(): void
|
||||
{
|
||||
$user = User::create([
|
||||
'email_hash' => User::hashEmail('test@example.com'),
|
||||
]);
|
||||
|
||||
$this->assertNull($user->email_verified_at);
|
||||
|
||||
$result = MagicLoginToken::generate('test@example.com', '127.0.0.1', 'TestAgent');
|
||||
|
||||
$this->post('/verify-code', [
|
||||
'email' => 'test@example.com',
|
||||
'code' => $result->plainCode,
|
||||
]);
|
||||
|
||||
$user->refresh();
|
||||
$this->assertNotNull($user->email_verified_at);
|
||||
}
|
||||
|
||||
public function test_already_verified_email_not_overwritten(): void
|
||||
{
|
||||
$verifiedAt = now()->subDays(30);
|
||||
|
||||
$user = User::create([
|
||||
'email_hash' => User::hashEmail('test@example.com'),
|
||||
'email_verified_at' => $verifiedAt,
|
||||
]);
|
||||
|
||||
$result = MagicLoginToken::generate('test@example.com', '127.0.0.1', 'TestAgent');
|
||||
|
||||
$signedUrl = URL::temporarySignedRoute(
|
||||
'magic-link.verify',
|
||||
now()->addMinutes(15),
|
||||
['token' => $result->plainToken]
|
||||
);
|
||||
|
||||
$this->get($signedUrl);
|
||||
|
||||
$user->refresh();
|
||||
$this->assertEquals(
|
||||
$verifiedAt->timestamp,
|
||||
$user->email_verified_at->timestamp,
|
||||
'Existing email_verified_at should not be overwritten on subsequent logins'
|
||||
);
|
||||
}
|
||||
|
||||
public function test_code_attempt_with_no_existing_token_is_rejected(): void
|
||||
{
|
||||
User::create([
|
||||
'email_hash' => User::hashEmail('test@example.com'),
|
||||
]);
|
||||
|
||||
// No token generated — no valid token exists for this email
|
||||
$response = $this->post('/verify-code', [
|
||||
'email' => 'test@example.com',
|
||||
'code' => '123456',
|
||||
]);
|
||||
|
||||
$response->assertSessionHasErrors('code');
|
||||
$this->assertGuest();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue