-
Notifications
You must be signed in to change notification settings - Fork 9
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Add UriBuilder to expose more OTP Auth URI parameters
- Loading branch information
1 parent
139fd0b
commit 35d58bb
Showing
7 changed files
with
413 additions
and
77 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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'; | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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'; | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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(); | ||
} | ||
} |
Oops, something went wrong.