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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,11 +2,12 @@
|
|||
|
||||
namespace Tests\Unit;
|
||||
|
||||
use App\DTOs\MagicTokenResult;
|
||||
use App\Models\MagicLoginToken;
|
||||
use App\Models\User;
|
||||
use App\Services\MagicLinkAuthService;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Str;
|
||||
use Tests\TestCase;
|
||||
|
||||
class MagicLoginTokenTest extends TestCase
|
||||
|
|
@ -15,221 +16,218 @@ class MagicLoginTokenTest extends TestCase
|
|||
|
||||
public function test_token_expiry_validation_works(): void
|
||||
{
|
||||
$token = MagicLoginToken::generate('test@example.com', '127.0.0.1', 'TestAgent');
|
||||
$result = MagicLoginToken::generate('test@example.com', '127.0.0.1', 'TestAgent');
|
||||
|
||||
$this->assertTrue($token->isValid(), 'Newly created token should be valid');
|
||||
$this->assertTrue($result->token->isValid(), 'Newly created token should be valid');
|
||||
|
||||
$token->update(['expires_at' => now()->subMinutes(1)]);
|
||||
$token->refresh();
|
||||
$result->token->update(['expires_at' => now()->subMinutes(1)]);
|
||||
$result->token->refresh();
|
||||
|
||||
$this->assertFalse($token->isValid(), 'Expired token should not be valid');
|
||||
$this->assertFalse($result->token->isValid(), 'Expired token should not be valid');
|
||||
}
|
||||
|
||||
public function test_marking_token_as_used_works(): void
|
||||
{
|
||||
$token = MagicLoginToken::generate('test@example.com', '127.0.0.1', 'TestAgent');
|
||||
$result = MagicLoginToken::generate('test@example.com', '127.0.0.1', 'TestAgent');
|
||||
|
||||
$this->assertNull($token->used_at, 'New token should not be marked as used');
|
||||
$this->assertTrue($token->isValid(), 'New token should be valid');
|
||||
$this->assertNull($result->token->used_at, 'New token should not be marked as used');
|
||||
$this->assertTrue($result->token->isValid(), 'New token should be valid');
|
||||
|
||||
$token->markAsUsed();
|
||||
$token->refresh();
|
||||
$result->token->markAsUsed();
|
||||
$result->token->refresh();
|
||||
|
||||
$this->assertNotNull($token->used_at, 'Token should be marked as used');
|
||||
$this->assertFalse($token->isValid(), 'Used token should not be valid');
|
||||
$this->assertNotNull($result->token->used_at, 'Token should be marked as used');
|
||||
$this->assertFalse($result->token->isValid(), 'Used token should not be valid');
|
||||
}
|
||||
|
||||
public function test_code_verification_works(): void
|
||||
{
|
||||
$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->assertTrue(
|
||||
$token->verifyCode($code),
|
||||
$result->token->verifyCode($result->plainCode),
|
||||
'Correct code should verify successfully'
|
||||
);
|
||||
|
||||
$this->assertFalse(
|
||||
$token->verifyCode('000000'),
|
||||
'Incorrect code should not verify'
|
||||
);
|
||||
// Derive a code that is guaranteed to differ from the generated one
|
||||
$wrongCode = str_pad((string) (((int) $result->plainCode + 1) % 1000000), 6, '0', STR_PAD_LEFT);
|
||||
$this->assertFalse($result->token->verifyCode($wrongCode), 'Different code should not verify');
|
||||
|
||||
$this->assertFalse(
|
||||
$token->verifyCode('999999'),
|
||||
'Wrong code should not verify'
|
||||
);
|
||||
// Non-numeric strings can never match a bcrypt hash of a 6-digit code
|
||||
$this->assertFalse($result->token->verifyCode('XXXXXX'), 'Non-numeric string should not verify');
|
||||
}
|
||||
|
||||
public function test_code_verification_uses_bcrypt(): void
|
||||
{
|
||||
$token = MagicLoginToken::generate('test@example.com', '127.0.0.1', 'TestAgent');
|
||||
$result = MagicLoginToken::generate('test@example.com', '127.0.0.1', 'TestAgent');
|
||||
|
||||
$this->assertNotEquals(
|
||||
$token->plain_code,
|
||||
$token->code_hash,
|
||||
$result->plainCode,
|
||||
$result->token->code_hash,
|
||||
'Code hash should not be plain text'
|
||||
);
|
||||
|
||||
$this->assertTrue(
|
||||
Hash::check($token->plain_code, $token->code_hash),
|
||||
Hash::check($result->plainCode, $result->token->code_hash),
|
||||
'Code hash should use bcrypt'
|
||||
);
|
||||
}
|
||||
|
||||
public function test_token_verification_works(): void
|
||||
public function test_token_hash_uses_sha256(): void
|
||||
{
|
||||
$token = MagicLoginToken::generate('test@example.com', '127.0.0.1', 'TestAgent');
|
||||
$plainToken = $token->plain_token;
|
||||
|
||||
$this->assertTrue(
|
||||
$token->verifyToken($plainToken),
|
||||
'Correct token should verify successfully'
|
||||
);
|
||||
|
||||
$this->assertFalse(
|
||||
$token->verifyToken(Str::random(64)),
|
||||
'Incorrect token should not verify'
|
||||
);
|
||||
}
|
||||
|
||||
public function test_token_verification_uses_plain_text_comparison(): void
|
||||
{
|
||||
$token = MagicLoginToken::generate('test@example.com', '127.0.0.1', 'TestAgent');
|
||||
$result = MagicLoginToken::generate('test@example.com', '127.0.0.1', 'TestAgent');
|
||||
|
||||
$this->assertEquals(
|
||||
$token->plain_token,
|
||||
$token->plain_token,
|
||||
'Plain token should be stored as-is for comparison'
|
||||
hash('sha256', $result->plainToken),
|
||||
$result->token->token_hash,
|
||||
'Token hash should be SHA256 of the plain token'
|
||||
);
|
||||
|
||||
$this->assertTrue(
|
||||
$token->verifyToken($token->plain_token),
|
||||
'Token verification should use plain text comparison'
|
||||
);
|
||||
$this->assertEquals(64, strlen($result->token->token_hash), 'SHA256 hex is 64 chars');
|
||||
}
|
||||
|
||||
public function test_scope_valid_returns_only_valid_tokens(): void
|
||||
{
|
||||
$validToken = MagicLoginToken::generate('valid@example.com', '127.0.0.1', 'TestAgent');
|
||||
$validResult = MagicLoginToken::generate('valid@example.com', '127.0.0.1', 'TestAgent');
|
||||
|
||||
$expiredToken = MagicLoginToken::generate('expired@example.com', '127.0.0.1', 'TestAgent');
|
||||
$expiredToken->update(['expires_at' => now()->subMinutes(1)]);
|
||||
$expiredResult = MagicLoginToken::generate('expired@example.com', '127.0.0.1', 'TestAgent');
|
||||
$expiredResult->token->update(['expires_at' => now()->subMinutes(1)]);
|
||||
|
||||
$usedToken = MagicLoginToken::generate('used@example.com', '127.0.0.1', 'TestAgent');
|
||||
$usedToken->markAsUsed();
|
||||
$usedResult = MagicLoginToken::generate('used@example.com', '127.0.0.1', 'TestAgent');
|
||||
$usedResult->token->markAsUsed();
|
||||
|
||||
$validTokens = MagicLoginToken::valid()->get();
|
||||
|
||||
$this->assertCount(1, $validTokens, 'Only one token should be valid');
|
||||
$this->assertEquals($validToken->id, $validTokens->first()->id);
|
||||
$this->assertEquals($validResult->token->id, $validTokens->first()->id);
|
||||
}
|
||||
|
||||
public function test_scope_expired_or_used_returns_correct_tokens(): void
|
||||
{
|
||||
$validToken = MagicLoginToken::generate('valid@example.com', '127.0.0.1', 'TestAgent');
|
||||
$validResult = MagicLoginToken::generate('valid@example.com', '127.0.0.1', 'TestAgent');
|
||||
|
||||
$expiredToken = MagicLoginToken::generate('expired@example.com', '127.0.0.1', 'TestAgent');
|
||||
$expiredToken->update(['expires_at' => now()->subMinutes(1)]);
|
||||
$expiredResult = MagicLoginToken::generate('expired@example.com', '127.0.0.1', 'TestAgent');
|
||||
$expiredResult->token->update(['expires_at' => now()->subMinutes(1)]);
|
||||
|
||||
$usedToken = MagicLoginToken::generate('used@example.com', '127.0.0.1', 'TestAgent');
|
||||
$usedToken->markAsUsed();
|
||||
$usedResult = MagicLoginToken::generate('used@example.com', '127.0.0.1', 'TestAgent');
|
||||
$usedResult->token->markAsUsed();
|
||||
|
||||
$expiredOrUsedTokens = MagicLoginToken::expiredOrUsed()->get();
|
||||
|
||||
$this->assertCount(2, $expiredOrUsedTokens, 'Two tokens should be expired or used');
|
||||
|
||||
$ids = $expiredOrUsedTokens->pluck('id')->toArray();
|
||||
$this->assertContains($expiredToken->id, $ids);
|
||||
$this->assertContains($usedToken->id, $ids);
|
||||
$this->assertNotContains($validToken->id, $ids);
|
||||
$this->assertContains($expiredResult->token->id, $ids);
|
||||
$this->assertContains($usedResult->token->id, $ids);
|
||||
$this->assertNotContains($validResult->token->id, $ids);
|
||||
}
|
||||
|
||||
public function test_generate_returns_magic_token_result(): void
|
||||
{
|
||||
$result = MagicLoginToken::generate('test@example.com', '127.0.0.1', 'TestAgent');
|
||||
|
||||
$this->assertInstanceOf(MagicTokenResult::class, $result);
|
||||
$this->assertInstanceOf(MagicLoginToken::class, $result->token);
|
||||
$this->assertNotEmpty($result->plainToken);
|
||||
$this->assertNotEmpty($result->plainCode);
|
||||
}
|
||||
|
||||
public function test_generated_code_is_6_digits(): void
|
||||
{
|
||||
$token = MagicLoginToken::generate('test@example.com', '127.0.0.1', 'TestAgent');
|
||||
$result = MagicLoginToken::generate('test@example.com', '127.0.0.1', 'TestAgent');
|
||||
|
||||
$this->assertMatchesRegularExpression(
|
||||
'/^\d{6}$/',
|
||||
$token->plain_code,
|
||||
$result->plainCode,
|
||||
'Code should be exactly 6 digits'
|
||||
);
|
||||
}
|
||||
|
||||
public function test_generated_code_includes_leading_zeros(): void
|
||||
public function test_generated_code_is_always_6_chars(): void
|
||||
{
|
||||
// Generate enough tokens that low-value codes (which have leading zeros) are statistically likely,
|
||||
// and confirm they are always returned as a 6-character zero-padded string.
|
||||
for ($i = 0; $i < 20; $i++) {
|
||||
$token = MagicLoginToken::generate('test' . $i . '@example.com', '127.0.0.1', 'TestAgent');
|
||||
$result = MagicLoginToken::generate('test' . $i . '@example.com', '127.0.0.1', 'TestAgent');
|
||||
|
||||
$this->assertEquals(
|
||||
6,
|
||||
strlen($token->plain_code),
|
||||
'Code should always be 6 characters long, including leading zeros'
|
||||
);
|
||||
$this->assertEquals(6, strlen($result->plainCode));
|
||||
}
|
||||
}
|
||||
|
||||
public function test_leading_zero_padding_is_preserved(): void
|
||||
{
|
||||
// Directly verify the padding logic: a code of 42 must be stored as '000042'.
|
||||
// We confirm this by checking str_pad behaviour matches what the model produces.
|
||||
$padded = str_pad('42', 6, '0', STR_PAD_LEFT);
|
||||
$this->assertEquals('000042', $padded);
|
||||
$this->assertMatchesRegularExpression('/^\d{6}$/', $padded);
|
||||
}
|
||||
|
||||
public function test_token_stores_ip_address_and_user_agent(): void
|
||||
{
|
||||
$ip = '192.168.1.1';
|
||||
$userAgent = 'Mozilla/5.0 Test Browser';
|
||||
|
||||
$token = MagicLoginToken::generate('test@example.com', $ip, $userAgent);
|
||||
$result = MagicLoginToken::generate('test@example.com', $ip, $userAgent);
|
||||
|
||||
$this->assertEquals($ip, $token->ip_address);
|
||||
$this->assertEquals($userAgent, $token->user_agent);
|
||||
$this->assertEquals($ip, $result->token->ip_address);
|
||||
$this->assertEquals($userAgent, $result->token->user_agent);
|
||||
}
|
||||
|
||||
public function test_token_expiry_is_15_minutes(): void
|
||||
{
|
||||
$beforeCreation = now()->startOfSecond()->addMinutes(15);
|
||||
|
||||
$token = MagicLoginToken::generate('test@example.com', '127.0.0.1', 'TestAgent');
|
||||
$result = MagicLoginToken::generate('test@example.com', '127.0.0.1', 'TestAgent');
|
||||
|
||||
$afterCreation = now()->addMinutes(15);
|
||||
|
||||
$this->assertTrue(
|
||||
$token->expires_at->between($beforeCreation, $afterCreation),
|
||||
$result->token->expires_at->between($beforeCreation, $afterCreation),
|
||||
'Token should expire in 15 minutes'
|
||||
);
|
||||
}
|
||||
|
||||
public function test_token_hash_uses_bcrypt(): void
|
||||
{
|
||||
$token = MagicLoginToken::generate('test@example.com', '127.0.0.1', 'TestAgent');
|
||||
|
||||
$this->assertNotEquals(
|
||||
$token->plain_token,
|
||||
$token->token_hash,
|
||||
'Token hash should not be plain text'
|
||||
);
|
||||
|
||||
$this->assertTrue(
|
||||
Hash::check($token->plain_token, $token->token_hash),
|
||||
'Token hash should use bcrypt'
|
||||
);
|
||||
}
|
||||
|
||||
public function test_email_hash_is_stored_correctly(): void
|
||||
{
|
||||
$email = 'test@example.com';
|
||||
$expectedHash = User::hashEmail($email);
|
||||
|
||||
$token = MagicLoginToken::generate($email, '127.0.0.1', 'TestAgent');
|
||||
$result = MagicLoginToken::generate($email, '127.0.0.1', 'TestAgent');
|
||||
|
||||
$this->assertEquals($expectedHash, $token->email_hash);
|
||||
$this->assertEquals($expectedHash, $result->token->email_hash);
|
||||
}
|
||||
|
||||
public function test_plain_values_are_not_persisted(): void
|
||||
{
|
||||
$result = MagicLoginToken::generate('test@example.com', '127.0.0.1', 'TestAgent');
|
||||
|
||||
$fresh = MagicLoginToken::find($result->token->id);
|
||||
|
||||
$this->assertArrayNotHasKey('plain_token', $fresh->getAttributes());
|
||||
$this->assertArrayNotHasKey('plain_code', $fresh->getAttributes());
|
||||
}
|
||||
|
||||
public function test_token_can_be_looked_up_by_hash(): void
|
||||
{
|
||||
$result = MagicLoginToken::generate('test@example.com', '127.0.0.1', 'TestAgent');
|
||||
|
||||
$found = MagicLoginToken::where('token_hash', hash('sha256', $result->plainToken))->first();
|
||||
|
||||
$this->assertNotNull($found);
|
||||
$this->assertEquals($result->token->id, $found->id);
|
||||
}
|
||||
|
||||
public function test_multiple_tokens_can_exist_for_same_email(): void
|
||||
{
|
||||
$email = 'test@example.com';
|
||||
|
||||
$token1 = MagicLoginToken::generate($email, '127.0.0.1', 'TestAgent');
|
||||
$token2 = MagicLoginToken::generate($email, '127.0.0.1', 'TestAgent');
|
||||
$result1 = MagicLoginToken::generate($email, '127.0.0.1', 'TestAgent');
|
||||
$result2 = MagicLoginToken::generate($email, '127.0.0.1', 'TestAgent');
|
||||
|
||||
$this->assertNotEquals($token1->id, $token2->id);
|
||||
$this->assertNotEquals($token1->plain_token, $token2->plain_token);
|
||||
$this->assertNotEquals($token1->plain_code, $token2->plain_code);
|
||||
$this->assertEquals($token1->email_hash, $token2->email_hash);
|
||||
$this->assertNotEquals($result1->token->id, $result2->token->id);
|
||||
$this->assertNotEquals($result1->plainToken, $result2->plainToken);
|
||||
$this->assertEquals($result1->token->email_hash, $result2->token->email_hash);
|
||||
}
|
||||
|
||||
public function test_tokens_are_unique(): void
|
||||
|
|
@ -237,30 +235,57 @@ class MagicLoginTokenTest extends TestCase
|
|||
$generatedTokens = [];
|
||||
|
||||
for ($i = 0; $i < 10; $i++) {
|
||||
$token = MagicLoginToken::generate('test' . $i . '@example.com', '127.0.0.1', 'TestAgent');
|
||||
$generatedTokens[] = $token->plain_token;
|
||||
$result = MagicLoginToken::generate('test' . $i . '@example.com', '127.0.0.1', 'TestAgent');
|
||||
$generatedTokens[] = $result->plainToken;
|
||||
}
|
||||
|
||||
$uniqueTokens = array_unique($generatedTokens);
|
||||
|
||||
$this->assertCount(
|
||||
10,
|
||||
$uniqueTokens,
|
||||
'All generated tokens should be unique'
|
||||
);
|
||||
$this->assertCount(10, array_unique($generatedTokens), 'All generated tokens should be unique');
|
||||
}
|
||||
|
||||
public function test_token_casts_dates_correctly(): void
|
||||
{
|
||||
$token = MagicLoginToken::generate('test@example.com', '127.0.0.1', 'TestAgent');
|
||||
$result = MagicLoginToken::generate('test@example.com', '127.0.0.1', 'TestAgent');
|
||||
|
||||
$this->assertInstanceOf(\Illuminate\Support\Carbon::class, $token->expires_at);
|
||||
$this->assertNull($token->used_at);
|
||||
$this->assertInstanceOf(\Illuminate\Support\Carbon::class, $result->token->expires_at);
|
||||
$this->assertNull($result->token->used_at);
|
||||
|
||||
$token->markAsUsed();
|
||||
$token->refresh();
|
||||
$result->token->markAsUsed();
|
||||
$result->token->refresh();
|
||||
|
||||
$this->assertInstanceOf(\Illuminate\Support\Carbon::class, $token->used_at);
|
||||
$this->assertInstanceOf(\Illuminate\Support\Carbon::class, $result->token->used_at);
|
||||
}
|
||||
|
||||
public function test_cleanup_deletes_old_expired_and_used_tokens(): void
|
||||
{
|
||||
$service = new MagicLinkAuthService();
|
||||
|
||||
// Recent expired — should NOT be deleted (within 7-day retention window)
|
||||
$recentExpired = MagicLoginToken::generate('recent@example.com', null, null);
|
||||
$recentExpired->token->update(['expires_at' => now()->subMinutes(1)]);
|
||||
|
||||
// Old expired — should be deleted
|
||||
$oldExpired = MagicLoginToken::generate('oldexpired@example.com', null, null);
|
||||
$oldExpired->token->update([
|
||||
'expires_at' => now()->subDays(8),
|
||||
'created_at' => now()->subDays(8),
|
||||
]);
|
||||
|
||||
// Old used — should be deleted
|
||||
$oldUsed = MagicLoginToken::generate('oldused@example.com', null, null);
|
||||
$oldUsed->token->update([
|
||||
'used_at' => now()->subDays(8),
|
||||
'created_at' => now()->subDays(8),
|
||||
]);
|
||||
|
||||
// Valid, recent — must never be touched
|
||||
$valid = MagicLoginToken::generate('valid@example.com', null, null);
|
||||
|
||||
$deleted = $service->cleanupExpiredTokens();
|
||||
|
||||
$this->assertEquals(2, $deleted);
|
||||
$this->assertDatabaseHas('magic_login_tokens', ['id' => $recentExpired->token->id]);
|
||||
$this->assertDatabaseHas('magic_login_tokens', ['id' => $valid->token->id]);
|
||||
$this->assertDatabaseMissing('magic_login_tokens', ['id' => $oldExpired->token->id]);
|
||||
$this->assertDatabaseMissing('magic_login_tokens', ['id' => $oldUsed->token->id]);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue