Laravel Auth

PHP MIT

Laravel Auth by GhostCompiler adds advanced authentication for Laravel with TOTP 2FA, passkeys via WebAuthn, OTP channels (email, SMS, WhatsApp), trusted devices, and tenant-aware social login.

Stars
2
Forks
0
Downloads
N/A
Open Issues
0
Files main

Repository Files

Loading file structure...
tests/Unit/TotpServiceTest.php
<?php

declare(strict_types=1);

namespace GhostCompiler\LaravelAuth\Tests\Unit;

use GhostCompiler\LaravelAuth\Services\TotpService;
use GhostCompiler\LaravelAuth\Support\Base32;
use GhostCompiler\LaravelAuth\Tests\TestCase;

class TotpServiceTest extends TestCase
{
    private TotpService $totp;

    protected function setUp(): void
    {
        parent::setUp();
        $this->totp = new TotpService;
    }

    public function test_generate_secret_returns_base32_encoded_string(): void
    {
        $secret = $this->totp->generateSecret();
        self::assertMatchesRegularExpression('/^[A-Z2-7]+$/', $secret);
    }

    public function test_generate_secret_returns_different_values_on_successive_calls(): void
    {
        $secret1 = $this->totp->generateSecret();
        $secret2 = $this->totp->generateSecret();
        self::assertNotSame($secret1, $secret2);
    }

    public function test_generate_secret_uses_20_bytes_by_default(): void
    {
        // 20 raw bytes → 32 base32 chars (20 * 8 / 5 = 32)
        $secret = $this->totp->generateSecret();
        self::assertSame(32, strlen($secret));
    }

    public function test_generate_secret_accepts_custom_byte_count(): void
    {
        // 10 raw bytes → 16 base32 chars
        $secret = $this->totp->generateSecret(10);
        self::assertSame(16, strlen($secret));
    }

    public function test_verify_rejects_non_numeric_code(): void
    {
        $secret = $this->totp->generateSecret();
        self::assertFalse($this->totp->verify($secret, 'abcdef', 1, 6, 30));
    }

    public function test_verify_rejects_code_with_wrong_length(): void
    {
        $secret = $this->totp->generateSecret();
        self::assertFalse($this->totp->verify($secret, '12345', 1, 6, 30));
        self::assertFalse($this->totp->verify($secret, '1234567', 1, 6, 30));
    }

    public function test_verify_rejects_incorrect_code(): void
    {
        $secret = $this->totp->generateSecret();
        // '000000' is astronomically unlikely to be the current code
        self::assertFalse($this->totp->verify($secret, '000000', 0, 6, 30));
    }

    public function test_verify_accepts_current_totp_code(): void
    {
        $secret = $this->totp->generateSecret();
        $code = $this->computeTotp($secret, 6, 30);
        self::assertTrue($this->totp->verify($secret, $code, 1, 6, 30));
    }

    public function test_verify_accepts_code_within_window(): void
    {
        $secret = $this->totp->generateSecret();
        $counter = (int) floor(time() / 30);
        // Code for the previous window step
        $previousCode = $this->computeTotpAt($secret, $counter - 1, 6);
        self::assertTrue($this->totp->verify($secret, $previousCode, 1, 6, 30));
    }

    public function test_verify_rejects_code_outside_window(): void
    {
        $secret = $this->totp->generateSecret();
        $counter = (int) floor(time() / 30);
        // Code 10 steps ago — well outside any sane window
        $staleCode = $this->computeTotpAt($secret, $counter - 10, 6);
        self::assertFalse($this->totp->verify($secret, $staleCode, 1, 6, 30));
    }

    public function test_verify_strips_whitespace_from_code(): void
    {
        $secret = $this->totp->generateSecret();
        $code = $this->computeTotp($secret, 6, 30);
        // Add spaces (e.g. "123 456" format)
        $spacedCode = substr($code, 0, 3).' '.substr($code, 3);
        self::assertTrue($this->totp->verify($secret, $spacedCode, 1, 6, 30));
    }

    public function test_provisioning_uri_has_correct_scheme_and_format(): void
    {
        $uri = $this->totp->provisioningUri('user@example.com', 'JBSWY3DPEHPK3PXP', 'MyApp', 6, 30);
        self::assertStringStartsWith('otpauth://totp/', $uri);
        self::assertStringContainsString('secret=JBSWY3DPEHPK3PXP', $uri);
        self::assertStringContainsString('issuer=MyApp', $uri);
        self::assertStringContainsString('digits=6', $uri);
        self::assertStringContainsString('period=30', $uri);
    }

    public function test_provisioning_uri_url_encodes_the_label(): void
    {
        $uri = $this->totp->provisioningUri('user@example.com', 'SECRET', 'My App', 6, 30);
        // The issuer:label part must be URL-encoded
        self::assertStringContainsString('My%20App%3Auser%40example.com', $uri);
    }

    // --- Helpers ---

    private function computeTotp(string $secret, int $digits, int $period): string
    {
        $counter = (int) floor(time() / $period);

        return $this->computeTotpAt($secret, $counter, $digits);
    }

    private function computeTotpAt(string $secret, int $counter, int $digits): string
    {
        $binarySecret = Base32::decode($secret);
        $packedCounter = pack('N*', 0).pack('N*', $counter);
        $hash = hash_hmac('sha1', $packedCounter, $binarySecret, true);
        $offset = ord(substr($hash, -1)) & 0x0F;
        $value = unpack('N', substr($hash, $offset, 4))[1] & 0x7FFFFFFF;

        return str_pad((string) ($value % (10 ** $digits)), $digits, '0', STR_PAD_LEFT);
    }
}