Generator losowych kodów w PHP

generator losowych kodów w php

Generowanie losowego ciągu znaków w programowaniu to popularne zagadnienie. Wynika to z tego, że w ten sposób można zabezpieczyć zasoby lub zapewnić ich unikalność. W większości technologii istnieją gotowe rozwiązania, które pozwalają na przykład generować UUID, czy innego rodzaju identyfikatory. Bardzo przydatna rzecz.

Czasem jednak istnieje potrzeba czegoś bardziej specyficznego. Mam na myśli krótsze losowe ciągi znaków. Mogą one pełnić rolę na przykład kodów dostępu, pinów czy po prostu haseł. Postanowiłem podzielić się jedną prostą klasą, której odpowiedzialnością jest właśnie dostarczenie tego rodzaju funkcjonalności.

Wymagania biznesowe, czyli łatwo i bezpiecznie

Zacznę od wymagań, które jak się okazuje mogą być bardzo różne. Tak, czy inaczej prawie zawsze staje się przed wyzwaniem długości kodu oraz jego zawartości. Te dwa czynniki muszą być kompromisem między bezpieczeństwem, a wygodą użytkowania. Im krótszy i mniej skomplikowany kod, tym łatwiejszy do złamania. Im dłuższy i bardziej skomplikowany kod tym mniej funkcjonalny z poziomu użytkownika.

Stąd też oba te parametry: długość i dozwolone znaki są argumentami przyjmowanymi przez metodę poniższej klasy. To pozwoli na reużywalność tego komponentu nawet w obrębie jednej aplikacji. W wielu miejscach systemu może istnieć potrzeba wygenerowania unikalnego kodu, ale o różnej złożoności. Dwa przykłady mogące mieć miejsce w jednej aplikacji to automatycznie generowane hasło dostępu i kod rabatowy. Hasło pewnie będzie wymagało znaków specjalnych, a już kod rabatowy może być zbudowany z samych cyfr. Długość zapewne też będzie inna.

Złożoność kodu zależy też od innych danych weryfikacyjnych. Gdyby wziąć pod lupę pin do karty płatniczej to zabezpieczenie w postaci 4 cyfr jest dość trywialne do złamania. Taka kombinacja daje maksymalnie 10 000 unikalnych kodów, co jest bardzo małą liczbą biorąc pod uwagę całą planetę i niektóre osoby mające po kilka takich kart. No ale właśnie – żeby z niej skorzystać to trzeba ją mieć. Poza tym istnieją dodatkowe zabezpieczenie w postaci ograniczenia liczby pomyłek do trzech. Gdyby płatność dokonywana była tylko i wyłącznie kodem to musiałby być on po pierwsze unikalny, a po drugie dużo bardziej skomplikowany. Już widzę osoby wpisujące w sklepie 20-znakowy ciąg.

W ostatnim czasie podobnie realizuje się recepty w aptekach. Podaje się 4-cyfrowy kod, ale dodatkowo swój pesel. Teraz unikalność to 10 000 kombinacji dla konkretnej osoby, nie całej ludzkości. Dodatkowo pewnie mają jakiś okres ważności jak to recepty, ale nie znam szczegółów implementacji. Tak więc jeżeli kod dostępowy jest jedynym zabezpieczeniem to im trudniejszy tym lepiej. Oczywiście zestawiając to z chronionym zasobem, czyli jak ważne jest żeby nie został przechwycony przez osobę nieuprawnioną.

Implementacja generatora losowego ciągu znaków w PHP

class CodeGenerator
{
    public const DEFAULT_LENGTH = 10;
    public const DEFAULT_CHARACTERS = 'aAbBcCdDeEfFgGhHiIjJkKlLmMnNoOpPrRsStTuUvVwWxXyYzZ0123456789';

    private CodeRepository $codeRepository;

    public function __construct(CodeRepository $codeRepository)
    {
        $this->codeRepository = $codeRepository;
    }

    public function generate(
        int $length = self::DEFAULT_LENGTH,
        string $allowedCharacters = self::DEFAULT_CHARACTERS
    ): string {
        if ($length < 3) {
            throw new \InvalidArgumentException('Code cannot be shorter than 3 chars');
        }

        $code = '';

        $allowedCharactersLength = mb_strlen($allowedCharacters);
        $codeLength = $length;
        while ($codeLength !== 0) {
            $randomIndex = mt_rand(0, $allowedCharactersLength - 1);
            $code .= $allowedCharacters[$randomIndex];

            $codeLength--;
        }

        if (!$this->codeRepository->isCodeUnique($code)) {
            return $this->generate($length, $allowedCharacters);
        }

        return $code;
    }
}
interface CodeRepository
{
    public function isCodeUnique(string $code): bool;
}
class CodeGeneratorTest extends TestCase
{
    public function testCannotGenerateNonUniqueCode(): void
    {
        $codeRepository = $this->prophesize(CodeRepository::class);
        $codeRepository
            ->isCodeUnique(Argument::type('string'))
            ->willReturn(false, false, true)
            ->shouldBeCalledTimes(3);
        $codeGenerator = new CodeGenerator($codeRepository->reveal());
        $codeGenerator->generate();
    }

    public function testCanGenerateCodeWithDefaultLength(): void
    {
        $codeRepository = $this->prophesize(CodeRepository::class);
        $codeRepository
            ->isCodeUnique(Argument::type('string'))
            ->willReturn(true);
        $codeGenerator = new CodeGenerator($codeRepository->reveal());
        $code = $codeGenerator->generate();

        $this->assertEquals(CodeGenerator::DEFAULT_LENGTH, strlen($code));
    }

    public function testCanGenerateCodeWithOtherLengthThanDefault(): void
    {
        $codeRepository = $this->prophesize(CodeRepository::class);
        $codeRepository
            ->isCodeUnique(Argument::type('string'))
            ->willReturn(true);
        $codeGenerator = new CodeGenerator($codeRepository->reveal());
        $code = $codeGenerator->generate(6);

        $this->assertSame(strlen($code), 6);
    }

    /** @dataProvider provideWrongCodeLength */
    public function testCannotGenerateCodeWithLengthLessThanThree(int $length): void
    {
        $codeRepository = $this->prophesize(CodeRepository::class);
        $codeGenerator = new CodeGenerator($codeRepository->reveal());

        $this->expectException(\InvalidArgumentException::class);

        $codeGenerator->generate($length);
    }

    public function testCannotGenerateCodeThatContainsOtherCharsThanSpecified()
    {
        $codeRepository = $this->prophesize(CodeRepository::class);
        $codeRepository
            ->isCodeUnique(Argument::type('string'))
            ->willReturn(true);
        $codeGenerator = new CodeGenerator($codeRepository->reveal());

        $allowedChars = 'aB0';
        $code = $codeGenerator->generate(20, $allowedChars);
        $regexChars = '/[^' . $allowedChars . ']/i';

        $this->assertEquals(0, preg_match($regexChars, $code));
    }

    public function provideWrongCodeLength(): array
    {
        return [
            'Length -1000' => [
                'length' => -1000
            ],
            'Length -1' => [
                'length' => -1
            ],
            'Length 0' => [
                'length' => 0
            ],
            'Length 1' => [
                'length' => 1
            ],
            'Length 2' => [
                'length' => 2
            ]
        ];
    }
}

Klasa ma zdefiniowane też dwie stałe, które określają parametry domyślne w generatorze. Kod o długości 10 znaków składający się z małych i dużych liter alfabetu oraz cyfr to całkiem skomplikowana mieszanka. W niektórych miejscach jednak zbyt skomplikowana, w innych nieco za łatwa. Stąd też argumenty te można zmieniać.

Zostaje jeszcze ostatnia kwestia – unikalność. W moim przypadku kody zapisywane są w bazie danych, dlatego też aby sprawdzić czy dany kod jest unikalny trzeba wykonać zapytanie. Nic w tym trudnego, prosta metoda isCodeUnique. Problem pojawia się z reużywalnością. Ważne, żeby nie wstrzykiwać konkretnego repozytorium, ale stworzyć interfejs wymuszający na klasie posiadanie tej metody. Dzięki temu można wykorzystywać klasę w wielu kontekstach, dla każdego repozytorium spełniającego interfejs CodeRepository.

Czy to wszystko?

Generator jest na tyle uniwersalny żeby z powodzeniem wykorzystywać go w aplikacji PHP. Ma dokładnie jedną odpowiedzialność – wygenerować losowy kod. Dzięki temu nie interesuje go co dzieje się dalej z dostarczonym przez niego ciągiem znaków. A dziać się może wiele, bo na przykład hasła będą trzymane w postaci zaszyfrowanej funkcją skrótu, a kod rabatowy ograniczany będzie okresową ważnością. Te specyficzne założenia będą potrzebne do sprawdzenia unikalności, dlatego wyseparowane zostały do metody isCodeUnique().

Kod sam w sobie jest bardzo prosty. Samą funkcjonalność można zaimplementować pewnie na wiele sposobów. W każdym razie w wielu aplikacjach taki feature ma swoje miejsce. Można go w zależności od potrzeb dostosować do wymagań, jednak taka bazowa wersja powinna sprawdzić się w dużej części miejsc.

Krystian Żądło
Programista PHP i właściciel marki Koddlo. Pasjonat czystego kodu i dobrych praktyk programowania obiektowego. Prywatnie fan angielskiej piłki nożnej, dobrego humoru oraz podcastów.