From 9eee447ec1232007f1c97bd1dad40b3eeb625492 Mon Sep 17 00:00:00 2001 From: bota Date: Wed, 16 Sep 2026 10:35:01 +0300 Subject: [PATCH] Issue #15: Split usage in 3 segments and fixed badge version Signed-off-by: bota --- README.md | 2 +- docs/book/v1/methods.md | 6 +- docs/book/v1/usage.md | 329 ------------------------- docs/book/v1/usage/app.md | 174 +++++++++++++ docs/book/v1/usage/email.md | 60 +++++ docs/book/v1/usage/sms.md | 60 +++++ docs/snippets/totp-columns-delivery.md | 8 + docs/snippets/totp-delivery-send.md | 15 ++ docs/snippets/totp-delivery-signin.md | 7 + docs/snippets/totp-delivery-verify.md | 27 ++ docs/snippets/totp-entity-mapping.md | 14 ++ docs/snippets/totp-methods-delivery.md | 11 + docs/snippets/totp-recovery-codes.md | 24 ++ docs/snippets/totp-service.md | 7 + docs/snippets/totp-trait-delivery.md | 140 +++++++++++ mkdocs.yml | 9 +- 16 files changed, 559 insertions(+), 334 deletions(-) delete mode 100644 docs/book/v1/usage.md create mode 100644 docs/book/v1/usage/app.md create mode 100644 docs/book/v1/usage/email.md create mode 100644 docs/book/v1/usage/sms.md create mode 100644 docs/snippets/totp-columns-delivery.md create mode 100644 docs/snippets/totp-delivery-send.md create mode 100644 docs/snippets/totp-delivery-signin.md create mode 100644 docs/snippets/totp-delivery-verify.md create mode 100644 docs/snippets/totp-entity-mapping.md create mode 100644 docs/snippets/totp-methods-delivery.md create mode 100644 docs/snippets/totp-recovery-codes.md create mode 100644 docs/snippets/totp-service.md create mode 100644 docs/snippets/totp-trait-delivery.md diff --git a/README.md b/README.md index 69d832d..d0d159f 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ Documentation is available at: https://docs.dotkernel.org/dot-totp/. ## Badges ![OSS Lifecycle](https://img.shields.io/osslifecycle/dotkernel/dot-totp) -![PHP from Packagist (specify version)](https://img.shields.io/packagist/dependency-v/dotkernel/dot-totp/php) +![PHP from Packagist (specify version)](https://img.shields.io/packagist/php-v/dotkernel/dot-totp/1.0.0) [![GitHub issues](https://img.shields.io/github/issues/dotkernel/dot-totp)](https://github.com/dotkernel/dot-totp/issues) [![GitHub forks](https://img.shields.io/github/forks/dotkernel/dot-totp)](https://github.com/dotkernel/dot-totp/network) diff --git a/docs/book/v1/methods.md b/docs/book/v1/methods.md index f9b958d..125dafe 100644 --- a/docs/book/v1/methods.md +++ b/docs/book/v1/methods.md @@ -4,9 +4,9 @@ A code is derived from a shared secret and the current time step, so the same co | Method | How the user gets the code | Secret lives | | --- | --- | --- | -| `app` | Scans a QR code once, then their device derives every code offline | Server and device | -| `email` | The server derives the code and sends it by email | Server only | -| `sms` | The server derives the code and sends it by SMS | Server only | +| [`app`](usage/app.md) | Scans a QR code once, then their device derives every code offline | Server and device | +| [`email`](usage/email.md) | The server derives the code and sends it by email | Server only | +| [`sms`](usage/sms.md) | The server derives the code and sends it by SMS | Server only | Generation and verification are the same two calls in all three cases. `getCode()` is what lets the server derive the code it expects, so email and SMS need no second implementation. diff --git a/docs/book/v1/usage.md b/docs/book/v1/usage.md deleted file mode 100644 index d384e1b..0000000 --- a/docs/book/v1/usage.md +++ /dev/null @@ -1,329 +0,0 @@ -# Usage - -The container gives you a single service, configured from `dot_totp.options`: - -```php -use Dot\Totp\Totp; - -$totp = $container->get(Totp::class); -``` - -| Method | Purpose | -| --- | --- | -| `generateSecretBase32(int $length = 16): string` | A new random Base32 secret. | -| `getCode(string $secret, ?int $timestamp = null): string` | The code for a secret at a moment in time; defaults to now. | -| `verifyCode(string $secret, string $code, int $window = 1): bool` | Whether a submission matches, checking `$window` steps either side of now. | -| `getProvisioningUri(string $label, string $issuer, string $secret): string` | The `otpauth://totp/...` URI an authenticator app scans. | -| `generateInlineSvgQr(string $data, int $size = 180): string` | An inline SVG QR code for that URI. | -| `generateRecoveryCodes(int $count = 8, int $length = 10, string $separator = '-'): array` | Single-use fallback codes in plain text. | -| `hashRecoveryCodes(array $codes): array` | The same codes hashed with `password_hash()`, for storage. | -| `validateRecoveryCode(string $inputCode, array &$hashedCodes): bool` | Consumes a matching code, **modifying the array by reference**. | -| `getPeriod(): int` / `getDigits(): int` / `getAlgorithm(): string` | The configured options. | - -## Entity trait - -Add the following trait to the entity that authenticates: - -```php - false])] - protected bool $totpEnabled = false; - - #[ORM\Column(name: 'totp_method', type: 'string', length: 16, options: ['default' => 'app'])] - protected string $totpMethod = self::TOTP_METHOD_APP; - - #[ORM\Column(name: 'totp_code_sent_at', type: 'datetime_immutable', nullable: true)] - protected ?DateTimeImmutable $totpCodeSentAt = null; - - #[ORM\Column(name: 'totp_attempts', type: 'smallint', options: ['default' => 0])] - protected int $totpAttempts = 0; - - /** @var array|null */ - #[ORM\Column(name: 'totp_recovery_codes', type: 'json', nullable: true)] - protected ?array $totpRecoveryCodes = null; - - public function enableTotp(string $secret, string $method = self::TOTP_METHOD_APP): self - { - $this->totpSecret = $secret; - $this->totpEnabled = true; - $this->totpMethod = $method; - - return $this; - } - - public function disableTotp(): self - { - $this->totpSecret = null; - $this->totpEnabled = false; - $this->totpMethod = self::TOTP_METHOD_APP; - $this->totpCodeSentAt = null; - $this->totpAttempts = 0; - $this->totpRecoveryCodes = null; - return $this; - } - - public function isTotpEnabled(): bool - { - return $this->totpEnabled; - } - - public function getTotpSecret(): ?string - { - return $this->totpSecret; - } - - public function rotateTotpSecret(string $secret): self - { - $this->totpSecret = $secret; - - return $this; - } - - public function getTotpMethod(): string - { - return $this->totpMethod; - } - - public function usesTotpDelivery(): bool - { - return $this->totpMethod !== self::TOTP_METHOD_APP; - } - - public function markTotpCodeSent(): self - { - $this->totpCodeSentAt = new DateTimeImmutable(); - $this->totpAttempts = 0; - - return $this; - } - - public function getTotpResendRetryAfter(int $resendInterval): int - { - if ($this->totpCodeSentAt === null) { - return 0; - } - - $elapsed = (new DateTimeImmutable())->getTimestamp() - $this->totpCodeSentAt->getTimestamp(); - - return max(0, $resendInterval - $elapsed); - } - - public function canResendTotpCode(int $resendInterval): bool - { - return $this->getTotpResendRetryAfter($resendInterval) === 0; - } - - public function registerFailedTotpAttempt(): self - { - $this->totpAttempts++; - - return $this; - } - - public function hasTotpAttemptsLeft(int $maxAttempts): bool - { - return $this->totpAttempts < $maxAttempts; - } - - /** - * @return array - */ - public function getTotpRecoveryCodes(): array - { - return $this->totpRecoveryCodes ?? []; - } - - /** - * @param array $hashedCodes - */ - public function setTotpRecoveryCodes(array $hashedCodes): self - { - $this->totpRecoveryCodes = $hashedCodes; - - return $this; - } -} -``` - -| Column | Needed for | Purpose | -| --- | --- | --- | -| `totp_secret` | all methods | The shared Base32 secret. | -| `totp_enabled` | all methods | Whether the second factor is on. | -| `totp_method` | all methods | `app`, `email` or `sms`, so the application knows how to deliver a code. | -| `totp_code_sent_at` | email, SMS | When the last code went out, driving the resend cooldown. | -| `totp_attempts` | email, SMS | Wrong guesses made against the code in flight. | -| `totp_recovery_codes` | recovery codes | Hashes of the codes not yet used. | - -For an authenticator app with no recovery codes, `totp_secret` and `totp_enabled` are the only columns you need. - -```php -#[ORM\Entity] -class User -{ - use TotpTrait; -} -``` - -`totp_method` and `totp_attempts` carry defaults so the `ALTER TABLE` succeeds on a populated table. -Then generate a migration: - -```shell -php bin/doctrine migrations:diff -``` - -> The method constants live on the trait, and PHP requires reading them through the class that uses it - `User::TOTP_METHOD_EMAIL`, not `TotpTrait::TOTP_METHOD_EMAIL`. - -## Enrolling a user - -An authenticator app enrols by scanning a QR code: - -```php -$secret = $totp->generateSecretBase32(); -$uri = $totp->getProvisioningUri($user->getIdentity(), 'Your App', $secret); -$svg = $totp->generateInlineSvgQr($uri); -``` - -`generateInlineSvgQr()` returns SVG markup, so the template embeds it directly - there is no image file to write or serve. - -Hold `$secret` in the session, not in the database, until the user has typed a code from their app. -Otherwise a QR code that was never scanned successfully leaves the user with a factor they cannot satisfy. - -```php -if (! $totp->verifyCode($secret, $submittedCode)) { - return $this->error('That code is not correct. Check the time on your phone and try again.'); -} - -$user->enableTotp($secret); -$entityManager->flush(); -``` - -Email and SMS have nothing to scan, so enrolment is one call with a secret the user never sees: - -```php -$user->enableTotp($totp->generateSecretBase32(), User::TOTP_METHOD_EMAIL); -$entityManager->flush(); -``` - -## Sending a code by email or SMS - -Check the cooldown, derive the code with `getCode()`, stamp the send, then deliver the plain code: - -```php -$resendInterval = 60; - -if (! $user->canResendTotpCode($resendInterval)) { - // 429, with Retry-After: $user->getTotpResendRetryAfter($resendInterval) - return $this->tooManyRequests($user->getTotpResendRetryAfter($resendInterval)); -} - -$code = $totp->getCode((string) $user->getTotpSecret()); - -$user->markTotpCodeSent(); -$entityManager->flush(); - -if ($user->getTotpMethod() === User::TOTP_METHOD_SMS) { - $this->smsGateway->send($user->getPhone(), sprintf('Your code is %s', $code)); -} else { - $this->mailer->send($user->getEmail(), sprintf('Your code is %s', $code)); -} -``` - -`markTotpCodeSent()` also resets the attempt counter, so guesses spent on the previous code do not count against the new one. - -Email delivery pairs with [dotkernel/dot-mail](https://docs.dotkernel.org/dot-mail/). -There is no Dotkernel SMS component, so `$this->smsGateway` is yours to provide. - -At sign-in, `usesTotpDelivery()` tells the two flows apart: an app user is asked for a code straight away, a delivery user needs one sent first. - -```php -if ($user->isTotpEnabled() && $user->usesTotpDelivery()) { - // send a code, then ask for it -} -``` - -## Verifying a code - -A code from an authenticator app is verified with the default window, which accepts the previous, current and next time step - about ninety seconds with a 30-second `period`, enough to absorb a phone's clock drift: - -```php -if (! $totp->verifyCode((string) $user->getTotpSecret(), $submittedCode)) { - return $this->error('That code is not correct.'); -} -``` - -A delivered code has to survive a mail queue or an SMS carrier, so it needs a wider window, an attempt limit, and a rotation once accepted: - -```php -$maxAttempts = 3; - -if (! $user->hasTotpAttemptsLeft($maxAttempts)) { - return $this->error('Too many attempts. Request a new code.'); -} - -// 30s period, 10 steps either side: roughly five minutes of grace -if (! $totp->verifyCode((string) $user->getTotpSecret(), $submittedCode, window: 10)) { - $user->registerFailedTotpAttempt(); - $entityManager->flush(); - - return $this->error('That code is not correct.'); -} - -// Accepted: rotate the secret so this code cannot be used again. -$user->rotateTotpSecret($totp->generateSecretBase32()); -$entityManager->flush(); -``` - -Pick the window from how long you are prepared to let a code live, divided by `period` - not from how long delivery usually takes. -A number that only just covers a fast send will reject legitimate users the first time your mail queue backs up. - -> Why the wider window needs the other two lines is explained in [Methods](methods.md). - -## Recovery codes - -Generate the codes once, show them once, and store only their hashes: - -```php -$codes = $totp->generateRecoveryCodes(); -// ['A2C4E-6GH8J', 'K3M5P-7QR9S', ...] - -$user->setTotpRecoveryCodes($totp->hashRecoveryCodes($codes)); -$entityManager->flush(); - -// $codes is the only chance the user has to write them down. -``` - -`validateRecoveryCode()` takes the hashes **by reference** and removes the one it consumes, so the mutated array has to be persisted or the code stays usable: - -```php -$hashedCodes = $user->getTotpRecoveryCodes(); - -if (! $totp->validateRecoveryCode($submittedCode, $hashedCodes)) { - return $this->error('That recovery code is not valid.'); -} - -$user->setTotpRecoveryCodes($hashedCodes); -$entityManager->flush(); -``` - -## Delivery method usage - -[Methods](methods.md) diff --git a/docs/book/v1/usage/app.md b/docs/book/v1/usage/app.md new file mode 100644 index 0000000..d7fa7a7 --- /dev/null +++ b/docs/book/v1/usage/app.md @@ -0,0 +1,174 @@ +# App + +The `app` method. The user scans a QR code once, and from then on their authenticator app derives every code offline - the code never travels, which makes this the strongest of the three methods. + +--8<-- "totp-service.md" + +| Method | Purpose | +| --- | --- | +| `generateSecretBase32(int $length = 16): string` | A new random Base32 secret. | +| `getProvisioningUri(string $label, string $issuer, string $secret): string` | The `otpauth://totp/...` URI an authenticator app scans. | +| `generateInlineSvgQr(string $data, int $size = 180): string` | An inline SVG QR code for that URI. | +| `verifyCode(string $secret, string $code, int $window = 1): bool` | Whether a submission matches, checking `$window` steps either side of now. | +| `generateRecoveryCodes(int $count = 8, int $length = 10, string $separator = '-'): array` | Single-use fallback codes in plain text. | +| `hashRecoveryCodes(array $codes): array` | The same codes hashed with `password_hash()`, for storage. | +| `validateRecoveryCode(string $inputCode, array &$hashedCodes): bool` | Consumes a matching code, **modifying the array by reference**. | +| `getPeriod(): int` / `getDigits(): int` / `getAlgorithm(): string` | The configured options. | + +`getCode()` is not listed: the user's device derives the code, so the server only ever verifies one. + +## Entity trait + +Add the following trait to the entity that authenticates. It carries only what the `app` method needs: + +```php + false])] + protected bool $totpEnabled = false; + + #[ORM\Column(name: 'totp_method', type: 'string', length: 16, options: ['default' => 'app'])] + protected string $totpMethod = self::TOTP_METHOD_APP; + + /** @var array|null */ + #[ORM\Column(name: 'totp_recovery_codes', type: 'json', nullable: true)] + protected ?array $totpRecoveryCodes = null; + + public function enableTotp(string $secret, string $method = self::TOTP_METHOD_APP): self + { + $this->totpSecret = $secret; + $this->totpEnabled = true; + $this->totpMethod = $method; + + return $this; + } + + public function disableTotp(): self + { + $this->totpSecret = null; + $this->totpEnabled = false; + $this->totpMethod = self::TOTP_METHOD_APP; + $this->totpRecoveryCodes = null; + + return $this; + } + + public function isTotpEnabled(): bool + { + return $this->totpEnabled; + } + + public function getTotpSecret(): ?string + { + return $this->totpSecret; + } + + public function getTotpMethod(): string + { + return $this->totpMethod; + } + + public function usesTotpDelivery(): bool + { + return $this->totpMethod !== self::TOTP_METHOD_APP; + } + + /** + * @return array + */ + public function getTotpRecoveryCodes(): array + { + return $this->totpRecoveryCodes ?? []; + } + + /** + * @param array $hashedCodes + */ + public function setTotpRecoveryCodes(array $hashedCodes): self + { + $this->totpRecoveryCodes = $hashedCodes; + + return $this; + } +} +``` + +| Column | Purpose | +| --- | --- | +| `totp_secret` | The shared Base32 secret. | +| `totp_enabled` | Whether the second factor is on. | +| `totp_method` | `app`, `email` or `sms`, so the application knows how to deliver a code. | +| `totp_recovery_codes` | Hashes of the codes not yet used. | + +With no recovery codes, `totp_secret` and `totp_enabled` are the only columns you need. + +This trait is a strict subset of the one on [Email](email.md) and [SMS](sms.md). Adding a delivery method later means adding members to it, never changing the ones above. + +--8<-- "totp-entity-mapping.md" + +> The method constants live on the trait, and PHP requires reading them through the class that uses it - `User::TOTP_METHOD_APP`, not `TotpTrait::TOTP_METHOD_APP`. + +## Enrolling a user + +An authenticator app enrols by scanning a QR code: + +```php +$secret = $totp->generateSecretBase32(); +$uri = $totp->getProvisioningUri($user->getIdentity(), 'Your App', $secret); +$svg = $totp->generateInlineSvgQr($uri); +``` + +`generateInlineSvgQr()` returns SVG markup, so the template embeds it directly - there is no image file to write or serve. + +Hold `$secret` in the session, not in the database, until the user has typed a code from their app. +Otherwise a QR code that was never scanned successfully leaves the user with a factor they cannot satisfy. + +```php +if (! $totp->verifyCode($secret, $submittedCode)) { + return $this->error('That code is not correct. Check the time on your phone and try again.'); +} + +$user->enableTotp($secret); +$entityManager->flush(); +``` + +`enableTotp()` defaults to `TOTP_METHOD_APP`, so no method argument is needed here. + +## Verifying a code + +At sign-in, a code from an authenticator app is verified with the default window, which accepts the previous, current and next time step - about ninety seconds with a 30-second `period`, enough to absorb a phone's clock drift: + +```php +if (! $totp->verifyCode((string) $user->getTotpSecret(), $submittedCode)) { + return $this->error('That code is not correct.'); +} +``` + +There is nothing to send first, so an app user is asked for a code straight away. `usesTotpDelivery()` returns `false` for this method, which is how a sign-in flow that also offers a delivery method tells the two apart. + +Nothing else is required: the secret stays put, and there is no attempt counter or resend cooldown to maintain. Those exist only for delivered codes, and [Methods](../methods.md) explains why. + +## Recovery codes + +If a user loses the device holding the secret, a recovery code is the only way back in. + +--8<-- "totp-recovery-codes.md" + +## Other methods + +[Email](email.md) and [SMS](sms.md) cover the two delivery methods. [Methods](../methods.md) compares all three. diff --git a/docs/book/v1/usage/email.md b/docs/book/v1/usage/email.md new file mode 100644 index 0000000..6c9501e --- /dev/null +++ b/docs/book/v1/usage/email.md @@ -0,0 +1,60 @@ +# Email + +The `email` method. The server derives the code and sends it by email, so the secret lives on the server only and the user has nothing to install. + +--8<-- "totp-service.md" + +--8<-- "totp-methods-delivery.md" + +## Entity trait + +Add the following trait to the entity that authenticates: + +--8<-- "totp-trait-delivery.md" + +--8<-- "totp-columns-delivery.md" + +--8<-- "totp-entity-mapping.md" + +> The method constants live on the trait, and PHP requires reading them through the class that uses it - `User::TOTP_METHOD_EMAIL`, not `TotpTrait::TOTP_METHOD_EMAIL`. + +## Enrolling a user + +Email has nothing to scan, so enrolment is one call with a secret the user never sees: + +```php +$user->enableTotp($totp->generateSecretBase32(), User::TOTP_METHOD_EMAIL); +$entityManager->flush(); +``` + +Send only to an address the user has already verified. + +## Sending a code + +Check the cooldown, derive the code with `getCode()` and stamp the send: + +--8<-- "totp-delivery-send.md" + +Then deliver the plain code: + +```php +$this->mailer->send($user->getEmail(), sprintf('Your code is %s', $code)); +``` + +Email delivery pairs with [dotkernel/dot-mail](https://docs.dotkernel.org/dot-mail/). + +--8<-- "totp-delivery-signin.md" + +## Verifying a code + +--8<-- "totp-delivery-verify.md" + +## Recovery codes + +A user who loses access to the mailbox needs another way in. + +--8<-- "totp-recovery-codes.md" + +## Other methods + +[App](app.md) and [SMS](sms.md) cover the other two methods. [Methods](../methods.md) compares all three. diff --git a/docs/book/v1/usage/sms.md b/docs/book/v1/usage/sms.md new file mode 100644 index 0000000..37fd012 --- /dev/null +++ b/docs/book/v1/usage/sms.md @@ -0,0 +1,60 @@ +# SMS + +The `sms` method. The server derives the code and sends it by SMS, so the secret lives on the server only and the user has nothing to install. + +--8<-- "totp-service.md" + +--8<-- "totp-methods-delivery.md" + +## Entity trait + +Add the following trait to the entity that authenticates: + +--8<-- "totp-trait-delivery.md" + +--8<-- "totp-columns-delivery.md" + +--8<-- "totp-entity-mapping.md" + +> The method constants live on the trait, and PHP requires reading them through the class that uses it - `User::TOTP_METHOD_SMS`, not `TotpTrait::TOTP_METHOD_SMS`. + +## Enrolling a user + +SMS has nothing to scan, so enrolment is one call with a secret the user never sees: + +```php +$user->enableTotp($totp->generateSecretBase32(), User::TOTP_METHOD_SMS); +$entityManager->flush(); +``` + +Send only to a phone number the user has already verified. + +## Sending a code + +Check the cooldown, derive the code with `getCode()` and stamp the send: + +--8<-- "totp-delivery-send.md" + +Then deliver the plain code: + +```php +$this->smsGateway->send($user->getPhone(), sprintf('Your code is %s', $code)); +``` + +There is no Dotkernel SMS component, so `$this->smsGateway` is yours to provide. + +--8<-- "totp-delivery-signin.md" + +## Verifying a code + +--8<-- "totp-delivery-verify.md" + +## Recovery codes + +A user who loses the phone number needs another way in. + +--8<-- "totp-recovery-codes.md" + +## Other methods + +[App](app.md) and [Email](email.md) cover the other two methods. [Methods](../methods.md) compares all three. diff --git a/docs/snippets/totp-columns-delivery.md b/docs/snippets/totp-columns-delivery.md new file mode 100644 index 0000000..a7c78f2 --- /dev/null +++ b/docs/snippets/totp-columns-delivery.md @@ -0,0 +1,8 @@ +| Column | Purpose | +| --- | --- | +| `totp_secret` | The shared Base32 secret. | +| `totp_enabled` | Whether the second factor is on. | +| `totp_method` | `app`, `email` or `sms`, so the application knows how to deliver a code. | +| `totp_code_sent_at` | When the last code went out, driving the resend cooldown. | +| `totp_attempts` | Wrong guesses made against the code in flight. | +| `totp_recovery_codes` | Hashes of the codes not yet used. | diff --git a/docs/snippets/totp-delivery-send.md b/docs/snippets/totp-delivery-send.md new file mode 100644 index 0000000..c57d1ff --- /dev/null +++ b/docs/snippets/totp-delivery-send.md @@ -0,0 +1,15 @@ +```php +$resendInterval = 60; + +if (! $user->canResendTotpCode($resendInterval)) { + // 429, with Retry-After: $user->getTotpResendRetryAfter($resendInterval) + return $this->tooManyRequests($user->getTotpResendRetryAfter($resendInterval)); +} + +$code = $totp->getCode((string) $user->getTotpSecret()); + +$user->markTotpCodeSent(); +$entityManager->flush(); +``` + +`markTotpCodeSent()` also resets the attempt counter, so guesses spent on the previous code do not count against the new one. diff --git a/docs/snippets/totp-delivery-signin.md b/docs/snippets/totp-delivery-signin.md new file mode 100644 index 0000000..55c2488 --- /dev/null +++ b/docs/snippets/totp-delivery-signin.md @@ -0,0 +1,7 @@ +At sign-in, `usesTotpDelivery()` tells the two flows apart: an app user is asked for a code straight away, a delivery user needs one sent first. + +```php +if ($user->isTotpEnabled() && $user->usesTotpDelivery()) { + // send a code, then ask for it +} +``` diff --git a/docs/snippets/totp-delivery-verify.md b/docs/snippets/totp-delivery-verify.md new file mode 100644 index 0000000..d8fe79c --- /dev/null +++ b/docs/snippets/totp-delivery-verify.md @@ -0,0 +1,27 @@ +A delivered code has to survive a mail queue or an SMS carrier, so it needs a wider window, an attempt limit, and a rotation once accepted: + +```php +$maxAttempts = 3; + +if (! $user->hasTotpAttemptsLeft($maxAttempts)) { + return $this->error('Too many attempts. Request a new code.'); +} + +// 30s period, window 5: 11 steps of 30s, so the code lives about five and a half minutes +if (! $totp->verifyCode((string) $user->getTotpSecret(), $submittedCode, window: 5)) { + $user->registerFailedTotpAttempt(); + $entityManager->flush(); + + return $this->error('That code is not correct.'); +} + +// Accepted: rotate the secret so this code cannot be used again. +$user->rotateTotpSecret($totp->generateSecretBase32()); +$entityManager->flush(); +``` + +`verifyCode()` accepts `$window` steps either side of now, so a code stays valid for `(2 * $window + 1) * period` seconds - half of that before the moment it was derived, half after. +Pick `$window` from how long you are prepared to let a code live, `(lifetime / period - 1) / 2`, not from how long delivery usually takes. +A number that only just covers a fast send will reject legitimate users the first time delivery backs up. + +> Why the wider window needs the other two lines is explained in [Methods](../methods.md). diff --git a/docs/snippets/totp-entity-mapping.md b/docs/snippets/totp-entity-mapping.md new file mode 100644 index 0000000..2686635 --- /dev/null +++ b/docs/snippets/totp-entity-mapping.md @@ -0,0 +1,14 @@ +```php +#[ORM\Entity] +class User +{ + use TotpTrait; +} +``` + +The `totp_*` columns carry defaults so the `ALTER TABLE` succeeds on a populated table. +Then generate a migration: + +```shell +php bin/doctrine migrations:diff +``` diff --git a/docs/snippets/totp-methods-delivery.md b/docs/snippets/totp-methods-delivery.md new file mode 100644 index 0000000..c849ea9 --- /dev/null +++ b/docs/snippets/totp-methods-delivery.md @@ -0,0 +1,11 @@ +| Method | Purpose | +| --- | --- | +| `generateSecretBase32(int $length = 16): string` | A new random Base32 secret. | +| `getCode(string $secret, ?int $timestamp = null): string` | The code for a secret at a moment in time; defaults to now. This is what lets the server derive the code it sends. | +| `verifyCode(string $secret, string $code, int $window = 1): bool` | Whether a submission matches, checking `$window` steps either side of now. | +| `generateRecoveryCodes(int $count = 8, int $length = 10, string $separator = '-'): array` | Single-use fallback codes in plain text. | +| `hashRecoveryCodes(array $codes): array` | The same codes hashed with `password_hash()`, for storage. | +| `validateRecoveryCode(string $inputCode, array &$hashedCodes): bool` | Consumes a matching code, **modifying the array by reference**. | +| `getPeriod(): int` / `getDigits(): int` / `getAlgorithm(): string` | The configured options. | + +`getProvisioningUri()` and `generateInlineSvgQr()` are not listed: a delivered code gives the user nothing to scan. diff --git a/docs/snippets/totp-recovery-codes.md b/docs/snippets/totp-recovery-codes.md new file mode 100644 index 0000000..bd0425c --- /dev/null +++ b/docs/snippets/totp-recovery-codes.md @@ -0,0 +1,24 @@ +Generate the codes once, show them once, and store only their hashes: + +```php +$codes = $totp->generateRecoveryCodes(); +// ['A2C4E-6GH8J', 'K3M5P-7QR9S', ...] + +$user->setTotpRecoveryCodes($totp->hashRecoveryCodes($codes)); +$entityManager->flush(); + +// $codes is the only chance the user has to write them down. +``` + +`validateRecoveryCode()` takes the hashes **by reference** and removes the one it consumes, so the mutated array has to be persisted or the code stays usable: + +```php +$hashedCodes = $user->getTotpRecoveryCodes(); + +if (! $totp->validateRecoveryCode($submittedCode, $hashedCodes)) { + return $this->error('That recovery code is not valid.'); +} + +$user->setTotpRecoveryCodes($hashedCodes); +$entityManager->flush(); +``` diff --git a/docs/snippets/totp-service.md b/docs/snippets/totp-service.md new file mode 100644 index 0000000..fff1d8c --- /dev/null +++ b/docs/snippets/totp-service.md @@ -0,0 +1,7 @@ +The container gives you a single service, configured from `dot_totp.options`: + +```php +use Dot\Totp\Totp; + +$totp = $container->get(Totp::class); +``` diff --git a/docs/snippets/totp-trait-delivery.md b/docs/snippets/totp-trait-delivery.md new file mode 100644 index 0000000..958b145 --- /dev/null +++ b/docs/snippets/totp-trait-delivery.md @@ -0,0 +1,140 @@ +```php + false])] + protected bool $totpEnabled = false; + + #[ORM\Column(name: 'totp_method', type: 'string', length: 16, options: ['default' => 'app'])] + protected string $totpMethod = self::TOTP_METHOD_APP; + + #[ORM\Column(name: 'totp_code_sent_at', type: 'datetime_immutable', nullable: true)] + protected ?DateTimeImmutable $totpCodeSentAt = null; + + #[ORM\Column(name: 'totp_attempts', type: 'smallint', options: ['default' => 0])] + protected int $totpAttempts = 0; + + /** @var array|null */ + #[ORM\Column(name: 'totp_recovery_codes', type: 'json', nullable: true)] + protected ?array $totpRecoveryCodes = null; + + public function enableTotp(string $secret, string $method = self::TOTP_METHOD_APP): self + { + $this->totpSecret = $secret; + $this->totpEnabled = true; + $this->totpMethod = $method; + + return $this; + } + + public function disableTotp(): self + { + $this->totpSecret = null; + $this->totpEnabled = false; + $this->totpMethod = self::TOTP_METHOD_APP; + $this->totpCodeSentAt = null; + $this->totpAttempts = 0; + $this->totpRecoveryCodes = null; + + return $this; + } + + public function isTotpEnabled(): bool + { + return $this->totpEnabled; + } + + public function getTotpSecret(): ?string + { + return $this->totpSecret; + } + + public function rotateTotpSecret(string $secret): self + { + $this->totpSecret = $secret; + + return $this; + } + + public function getTotpMethod(): string + { + return $this->totpMethod; + } + + public function usesTotpDelivery(): bool + { + return $this->totpMethod !== self::TOTP_METHOD_APP; + } + + public function markTotpCodeSent(): self + { + $this->totpCodeSentAt = new DateTimeImmutable(); + $this->totpAttempts = 0; + + return $this; + } + + public function getTotpResendRetryAfter(int $resendInterval): int + { + if ($this->totpCodeSentAt === null) { + return 0; + } + + $elapsed = (new DateTimeImmutable())->getTimestamp() - $this->totpCodeSentAt->getTimestamp(); + + return max(0, $resendInterval - $elapsed); + } + + public function canResendTotpCode(int $resendInterval): bool + { + return $this->getTotpResendRetryAfter($resendInterval) === 0; + } + + public function registerFailedTotpAttempt(): self + { + $this->totpAttempts++; + + return $this; + } + + public function hasTotpAttemptsLeft(int $maxAttempts): bool + { + return $this->totpAttempts < $maxAttempts; + } + + /** + * @return array + */ + public function getTotpRecoveryCodes(): array + { + return $this->totpRecoveryCodes ?? []; + } + + /** + * @param array $hashedCodes + */ + public function setTotpRecoveryCodes(array $hashedCodes): self + { + $this->totpRecoveryCodes = $hashedCodes; + + return $this; + } +} +``` diff --git a/mkdocs.yml b/mkdocs.yml index 91735b6..1c0ae61 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -11,10 +11,17 @@ nav: - Overview: v1/overview.md - Installation: v1/installation.md - Configuration: v1/configuration.md - - Usage: v1/usage.md + - Usage: + - App: v1/usage/app.md + - Email: v1/usage/email.md + - SMS: v1/usage/sms.md - Methods: v1/methods.md site_name: dot-totp site_description: "DotKernel TOTP authorization component" repo_url: "https://github.com/dotkernel/dot-totp" +markdown_extensions: + - pymdownx.snippets: + base_path: + - docs/snippets plugins: - search