Skip to content

Commit

Permalink
Add UriBuilder to expose more OTP Auth URI parameters
Browse files Browse the repository at this point in the history
  • Loading branch information
jdpanderson committed Jan 31, 2024
1 parent 139fd0b commit 35d58bb
Show file tree
Hide file tree
Showing 7 changed files with 413 additions and 77 deletions.
99 changes: 22 additions & 77 deletions src/GoogleAuthenticator.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@
use Endroid\QrCode\Builder\Builder;
use Endroid\QrCode\Writer\PngWriter;
use Exception;
use Vectorface\OtpAuth\Base32;
use Vectorface\OtpAuth\UriBuilder;

/**
* PHP Class for handling Google Authenticator 2-factor authentication
Expand All @@ -29,8 +31,6 @@ class GoogleAuthenticator
*/
public function createSecret(int $secretLength = 16) : string
{
$validChars = self::base32LookupTable();

// Valid secret lengths are 80 to 640 bits
if ($secretLength < 16 || $secretLength > 128) {
throw new Exception('Bad secret length');
Expand All @@ -47,12 +47,7 @@ public function createSecret(int $secretLength = 16) : string
}
// @codeCoverageIgnoreEnd

$secret = '';
for ($i = 0; $i < $secretLength; ++$i) {
$secret .= $validChars[ord($rnd[$i]) & 31];
}

return $secret;
return Base32::encode($rnd, $secretLength);
}

/**
Expand All @@ -69,7 +64,7 @@ public function getCode(string $secret, int $timeSlice = null) : string
$timeSlice = floor(time() / 30);
}

$secretkey = self::base32Decode($secret);
$secretkey = Base32::decode($secret);
if (empty($secretkey)) {
throw new Exception('Could not decode secret');
}
Expand Down Expand Up @@ -104,10 +99,27 @@ public function getCode(string $secret, int $timeSlice = null) : string
*/
public function getQRCodeUrl(string $name, string $secret) : string
{
$uri = "otpauth://totp/$name?secret=$secret";
$uri = $this->getUriBuilder()
->account($name)
->secret($secret)
->getUri();
return $this->getQRCodeDataUri($uri);
}

/**
* Build an OTP URI using the builder pattern
*
* @return UriBuilder
*/
public function getUriBuilder(): UriBuilder
{
$builder = new UriBuilder();
if ($this->_codeLength !== 6) {
$builder->digits($this->_codeLength);
}
return $builder;
}

/**
* Generate a QRCode for a given string
*
Expand Down Expand Up @@ -168,72 +180,5 @@ public function setCodeLength(int $length) : self
$this->_codeLength = $length;
return $this;
}

/**
* Helper class to decode base32
*
* @param string $secret
* @return string
*/
private static function base32Decode(string $secret) : string
{
if (empty($secret)) {
return '';
}

$base32chars = self::base32LookupTable();
$base32charsFlipped = array_flip($base32chars);

$paddingCharCount = substr_count($secret, $base32chars[32]);
$allowedValues = [6, 4, 3, 1, 0];
if (!in_array($paddingCharCount, $allowedValues)) {
return '';
}

for ($i = 0; $i < 4; $i++){
if ($paddingCharCount == $allowedValues[$i] &&
substr($secret, -($allowedValues[$i])) != str_repeat($base32chars[32], $allowedValues[$i])) {
return '';
}
}

$secret = str_replace('=','', $secret);
$secret = str_split($secret);
$binaryString = "";
for ($i = 0; $i < count($secret); $i = $i+8) {
if (!in_array($secret[$i], $base32chars)) {
return '';
}

$x = "";
for ($j = 0; $j < 8; $j++) {
$secretChar = $secret[$i + $j] ?? 0;
$base = $base32charsFlipped[$secretChar] ?? 0;
$x .= str_pad(base_convert($base, 10, 2), 5, '0', STR_PAD_LEFT);
}
$eightBits = str_split($x, 8);
for ($z = 0; $z < count($eightBits); $z++) {
$binaryString .= ( ($y = chr(base_convert($eightBits[$z], 2, 10))) || ord($y) == 48 ) ? $y : "";
}
}

return $binaryString;
}

/**
* Get array with all 32 characters for decoding from/encoding to base32
*
* @return array
*/
private static function base32LookupTable() : array
{
return [
'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', // 7
'I', 'J', 'K', 'L', 'M', 'N', 'O', 'P', // 15
'Q', 'R', 'S', 'T', 'U', 'V', 'W', 'X', // 23
'Y', 'Z', '2', '3', '4', '5', '6', '7', // 31
'=' // padding char
];
}
}

80 changes: 80 additions & 0 deletions src/OtpAuth/Base32.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
<?php

namespace Vectorface\OtpAuth;

class Base32
{
const CHARS = [
'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', // 7
'I', 'J', 'K', 'L', 'M', 'N', 'O', 'P', // 15
'Q', 'R', 'S', 'T', 'U', 'V', 'W', 'X', // 23
'Y', 'Z', '2', '3', '4', '5', '6', '7', // 31
'=' // 32, padding character
];

/**
* Helper method to encode base32
*
* @param string $data
* @param $length
* @return string
*/
public static function encode(string $data, $length = null): string
{
$length ??= strlen($data);
$encoded = '';
for ($i = 0; $i < $length; ++$i) {
$encoded .= self::CHARS[ord($data[$i]) & 31];
}
return $encoded;
}

/**
* Helper method to decode base32
*
* @param string $data
* @return ?string The decoded string, or null on error
*/
public static function decode(string $data): ?string
{
if (empty($data)) {
return '';
}

$base32charsFlipped = array_flip(self::CHARS);
$paddingCharCount = substr_count($data, self::CHARS[32]);
$allowedValues = [6, 4, 3, 1, 0];
if (!in_array($paddingCharCount, $allowedValues)) {
return null;
}

for ($i = 0; $i < 4; $i++){
if ($paddingCharCount == $allowedValues[$i] &&
substr($data, -($allowedValues[$i])) != str_repeat(self::CHARS[32], $allowedValues[$i])) {
return null;
}
}

$data = str_replace('=','', $data);
$data = str_split($data);
$binaryString = "";
for ($i = 0; $i < count($data); $i = $i+8) {
if (!isset($base32charsFlipped[$data[$i]])) {
return null;
}

$x = "";
for ($j = 0; $j < 8; $j++) {
$secretChar = $data[$i + $j] ?? 0;
$base = $base32charsFlipped[$secretChar] ?? 0;
$x .= str_pad(base_convert($base, 10, 2), 5, '0', STR_PAD_LEFT);
}
$eightBits = str_split($x, 8);
for ($z = 0; $z < count($eightBits); $z++) {
$binaryString .= ( ($y = chr(base_convert($eightBits[$z], 2, 10))) || ord($y) == 48 ) ? $y : "";
}
}

return $binaryString;
}
}
10 changes: 10 additions & 0 deletions src/OtpAuth/Paramters/Algorithm.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
<?php

namespace Vectorface\OtpAuth\Paramters;

enum Algorithm: string
{
case SHA1 = 'SHA1';
case SHA256 = 'SHA256';
case SHA512 = 'SHA512';
}
9 changes: 9 additions & 0 deletions src/OtpAuth/Paramters/Type.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
<?php

namespace Vectorface\OtpAuth\Paramters;

enum Type: string
{
case TOTP = 'totp';
case HOTP = 'hotp';
}
139 changes: 139 additions & 0 deletions src/OtpAuth/UriBuilder.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
<?php

namespace Vectorface\OtpAuth;

use Vectorface\OtpAuth\Paramters\Algorithm;
use Vectorface\OtpAuth\Paramters\Type;

/**
* A TOTP/HOTP URI builder
*
* URIs should be in the format:
* otpauth://TYPE/LABEL?PARAMETERS
*
* Where:
* - TYPE is one of "totp" (default) or "hotp"
* - LABEL is the account or issue: account (encoded according to rfc3986)
* - PARAMETERS are a set of encoded parameters that may/must include:
* - secret: A base32-encoded secret (rfc3548)
* - issuer: The provider or service with which the account is associated
* - algorithm: One of sha1 (default), sha256, or sha512
* - digits: Either 6 or 8
*/
class UriBuilder
{
const SCHEME = "otpauth";
const DIGITS = [6, 8];

private string $secret;
private string $account = '';
private ?string $issuer = null;
private Type $type = Type::TOTP;
private ?Algorithm $algorithm = null;
private ?int $digits = null;
private ?int $counter = null;
private ?int $period = null;

/**
* @param string $secret
* @param bool $encode If true, also base32 encode the secret
* @return $this
*/
public function secret(string $secret, bool $encode = false): self
{
$this->secret = $encode ? Base32::encode($secret) : $secret;
return $this;
}

public function account(string $account): self
{
$this->account = $account;
return $this;
}

public function issuer(string $issuer): self
{
$this->issuer = $issuer;
return $this;
}

public function type(Type $type): self
{
$this->type = $type;
return $this;
}

public function algorithm(Algorithm $algorithm): self
{
$this->algorithm = $algorithm;
return $this;
}

public function digits(int $digits): self
{
if (!in_array($digits, self::DIGITS)) {
throw new \InvalidArgumentException("Number of digits must be 6 or 8");
}
$this->digits = $digits;
return $this;
}

public function counter(int $counter): self
{
if ($counter < 0) {
throw new \InvalidArgumentException("Counter must be an integer greater than or equal to zero");
}
$this->counter = $counter;
return $this;
}

public function period(int $period): self
{
if ($period < 1) {
throw new \InvalidArgumentException("Period must be an integer greater than zero");
}
$this->period = $period;
return $this;
}

public function getUri(): string
{
if (!isset($this->secret)) {
throw new \DomainException("Secret is required for OTP URIs");
}

if ($this->type === Type::HOTP && !isset($this->counter)) {
throw new \DomainException("Counter is a required HOTP parameter");
}

if ($this->type === Type::TOTP && isset($this->counter)) {
throw new \DomainException("Counter parameter does not apply to TOTP");
}

if ($this->type === Type::HOTP && isset($this->period)) {
throw new \DomainException("Period parameter does not apply to HOTP");
}

$params = array_filter([
'secret' => $this->secret,
'issuer' => empty($this->issuer) ? $this->issuer : rawurlencode($this->issuer),
'algorithm' => $this->algorithm?->value,
'digits' => $this->digits,
'counter' => $this->counter,
'period' => $this->period,
]);

return sprintf(
"%s://%s/%s?%s",
self::SCHEME,
$this->type->value,
(empty($this->issuer) ? "" : (rawurlencode($this->issuer) . ":%20")) . rawurlencode($this->account),
implode('&', array_map(fn($k, $v) => "$k=$v", array_keys($params), array_values($params)))
);
}

public function __toString(): string
{
return $this->getUri();
}
}
Loading

0 comments on commit 35d58bb

Please sign in to comment.