Skip to content

Commit 4fb7144

Browse files
committed
Add support for 2FA TOTP
1 parent a9c1518 commit 4fb7144

File tree

3 files changed

+397
-0
lines changed

3 files changed

+397
-0
lines changed

src/Security/OTP/TOTP.php

Lines changed: 286 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,286 @@
1+
<?php
2+
3+
/**
4+
* Platine Framework
5+
*
6+
* Platine Framework is a lightweight, high-performance, simple and elegant PHP
7+
* Web framework
8+
*
9+
* This content is released under the MIT License (MIT)
10+
*
11+
* Copyright (c) 2020 Platine Framework
12+
* Copyright (c) 2024 Wildy Sheverando
13+
*
14+
* Permission is hereby granted, free of charge, to any person obtaining a copy
15+
* of this software and associated documentation files (the "Software"), to deal
16+
* in the Software without restriction, including without limitation the rights
17+
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
18+
* copies of the Software, and to permit persons to whom the Software is
19+
* furnished to do so, subject to the following conditions:
20+
*
21+
* The above copyright notice and this permission notice shall be included in all
22+
* copies or substantial portions of the Software.
23+
*
24+
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
25+
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
26+
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
27+
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
28+
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
29+
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
30+
* SOFTWARE.
31+
*/
32+
33+
/**
34+
* @file TOTP.php
35+
*
36+
* The TOTP class
37+
*
38+
* @package Platine\Framework\Security\OTP
39+
* @author Platine Developers team
40+
* @copyright Copyright (c) 2020
41+
* @license http://opensource.org/licenses/MIT MIT License
42+
* @link https://www.platine-php.com
43+
* @version 1.0.0
44+
* @filesource
45+
*/
46+
47+
declare(strict_types=1);
48+
49+
namespace Platine\Framework\Security\OTP;
50+
51+
/**
52+
* @class TOTP
53+
* @package Platine\Framework\Security\OTP
54+
*/
55+
class TOTP
56+
{
57+
/**
58+
* The length of the secret
59+
* @var int
60+
*/
61+
protected int $secretLength = 16;
62+
63+
/**
64+
* The time step to be used
65+
* @var int
66+
*/
67+
protected int $timeStep = 30;
68+
69+
/**
70+
* The supported digit length
71+
* @var int
72+
*/
73+
protected int $digit = 6;
74+
75+
/**
76+
* The secret key to use
77+
* @var string
78+
*/
79+
protected string $secret;
80+
81+
/**
82+
* Create new instance
83+
* @param string|null $secret
84+
*/
85+
public function __construct(?string $secret = null)
86+
{
87+
if ($secret === null) {
88+
$secret = $this->generateSecret();
89+
}
90+
91+
$this->secret = $secret;
92+
}
93+
94+
/**
95+
* Return the current code (auth)
96+
* @param string|null $secret
97+
* @return string
98+
*/
99+
public function getCode(?string $secret = null): string
100+
{
101+
if ($secret === null) {
102+
$secret = $this->secret;
103+
}
104+
105+
/*
106+
how it's work ?
107+
1. Decode secret key from Base32
108+
2. Count time step
109+
3. Pack counter time to binary strings.
110+
4. Hashing the timehex and secret to sha1
111+
5. Get offset from hash
112+
6. Generate binary code
113+
7. Convert it to strings.
114+
*/
115+
$secretKey = $this->base32Decode($secret);
116+
$timeCounter = floor(time() / $this->timeStep);
117+
$timeHex = pack('N*', 0) . pack('N*', $timeCounter);
118+
// TODO: use support to set custom algorithm
119+
$hash = hash_hmac('sha1', $timeHex, $secretKey, true);
120+
$offset = ord($hash[strlen($hash) - 1]) & 0xF;
121+
122+
$binary = ((ord($hash[$offset]) & 0x7F) << 24)
123+
| ((ord($hash[$offset + 1]) & 0xFF) << 16)
124+
| ((ord($hash[$offset + 2]) & 0xFF) << 8)
125+
| (ord($hash[$offset + 3]) & 0xFF);
126+
127+
$otp = $binary % pow(10, $this->digit);
128+
129+
return str_pad((string) $otp, $this->digit, '0', STR_PAD_LEFT);
130+
}
131+
132+
/**
133+
* Verify the given code
134+
* @param string $code
135+
* @param string|null $secret
136+
* @return bool
137+
*/
138+
public function verify(string $code, ?string $secret = null): bool
139+
{
140+
return $this->getCode($secret) === $code;
141+
}
142+
143+
/**
144+
* Return the URL to be used to generated QR Code or import in authenticator app
145+
* @param string $label
146+
* @param string $issuer
147+
* @return string
148+
*/
149+
public function getURL(string $label, string $issuer = ''): string
150+
{
151+
$secret = $this->getSecret();
152+
$labelEncode = rawurlencode($label);
153+
$issuerEncode = rawurlencode($issuer);
154+
155+
return sprintf(
156+
'otpauth://totp/%s?secret=%s&issuer=%s',
157+
$labelEncode,
158+
$secret,
159+
$issuerEncode
160+
);
161+
}
162+
163+
/**
164+
* Generate the secret key
165+
* @return string
166+
*/
167+
public function generateSecret(): string
168+
{
169+
$chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ23456';
170+
$length = strlen($chars) - 1;
171+
$random = '';
172+
for ($i = 0; $i < $this->secretLength; $i++) {
173+
$random .= $chars[random_int(0, $length)];
174+
}
175+
176+
return $random;
177+
}
178+
179+
/**
180+
* Return the secret length
181+
* @return int
182+
*/
183+
public function getSecretLength(): int
184+
{
185+
return $this->secretLength;
186+
}
187+
188+
/**
189+
* Return the time step
190+
* @return int
191+
*/
192+
public function getTimeStep(): int
193+
{
194+
return $this->timeStep;
195+
}
196+
197+
/**
198+
* Return the code supported digit
199+
* @return int
200+
*/
201+
public function getDigit(): int
202+
{
203+
return $this->digit;
204+
}
205+
206+
/**
207+
* Return the secret
208+
* @return string
209+
*/
210+
public function getSecret(): string
211+
{
212+
return $this->secret;
213+
}
214+
215+
/**
216+
* Set the secret length
217+
* @param int $secretLength
218+
* @return $this
219+
*/
220+
public function setSecretLength(int $secretLength): self
221+
{
222+
$this->secretLength = $secretLength;
223+
return $this;
224+
}
225+
226+
/**
227+
* Set the time step
228+
* @param int $timeStep
229+
* @return $this
230+
*/
231+
public function setTimeStep(int $timeStep): self
232+
{
233+
$this->timeStep = $timeStep;
234+
return $this;
235+
}
236+
237+
/**
238+
* Set the code supported digit
239+
* @param int $digit
240+
* @return $this
241+
*/
242+
public function setDigit(int $digit): self
243+
{
244+
$this->digit = $digit;
245+
return $this;
246+
}
247+
248+
/**
249+
* Set the secret
250+
* @param string $secret
251+
* @return $this
252+
*/
253+
public function setSecret(string $secret): self
254+
{
255+
$this->secret = $secret;
256+
return $this;
257+
}
258+
259+
/**
260+
* Base32 decoding
261+
* @param string $str
262+
* @return string
263+
*/
264+
protected function base32Decode(string $str): string
265+
{
266+
$chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ23456';
267+
$string = strtoupper($str);
268+
$length = strlen($string);
269+
270+
$n = 0;
271+
$j = 0;
272+
$binary = '';
273+
for ($i = 0; $i < $length; $i++) {
274+
$n = $n << 5;
275+
$n = $n + strpos($chars, $string[$i]);
276+
$j += 5;
277+
278+
if ($j >= 8) {
279+
$j -= 8;
280+
$binary .= chr(($n & (0xFF << $j)) >> $j);
281+
}
282+
}
283+
284+
return $binary;
285+
}
286+
}

tests/Security/OTP/TOTPTest.php

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Platine\Test\Framework\Security\OTP;
6+
7+
use Platine\Dev\PlatineTestCase;
8+
use Platine\Framework\Security\OTP\TOTP;
9+
10+
/*
11+
* @group core
12+
* @group framework
13+
*/
14+
class TOTPTest extends PlatineTestCase
15+
{
16+
public function testConstructSecretIsNull(): void
17+
{
18+
global $mock_random_int;
19+
$mock_random_int = true;
20+
21+
$o = new TOTP(null);
22+
$this->assertEquals('BBBBBBBBBBBBBBBB', $o->getSecret());
23+
}
24+
25+
public function testConstructSecretIsSet(): void
26+
{
27+
$o = new TOTP('FOOBAR');
28+
$this->assertEquals('FOOBAR', $o->getSecret());
29+
}
30+
31+
public function testGetCode(): void
32+
{
33+
global $mock_str_pad_to_value;
34+
$mock_str_pad_to_value = '147570';
35+
36+
$o = new TOTP();
37+
$code = $o->getCode(null);
38+
$this->assertEquals('147570', $code);
39+
}
40+
41+
public function testGetCodeCustomSecret(): void
42+
{
43+
global $mock_str_pad_to_value;
44+
$mock_str_pad_to_value = '326390';
45+
46+
$o = new TOTP();
47+
$code = $o->getCode('JX4JXO3HWYRZOKSU');
48+
$this->assertEquals('326390', $code);
49+
}
50+
51+
public function testVerify(): void
52+
{
53+
global $mock_str_pad_to_value;
54+
$mock_str_pad_to_value = '147570';
55+
56+
$o = new TOTP();
57+
$this->assertTrue($o->verify('147570', null));
58+
$this->assertFalse($o->verify('247570', null));
59+
}
60+
61+
public function testGetURL(): void
62+
{
63+
global $mock_random_int;
64+
$mock_random_int = true;
65+
66+
$o = new TOTP();
67+
$url = $o->getURL('Tony', 'Platine App');
68+
$this->assertEquals('otpauth://totp/Tony?secret=BBBBBBBBBBBBBBBB&issuer=Platine%20App', $url);
69+
}
70+
71+
72+
public function testGetSet(): void
73+
{
74+
$o = new TOTP(null);
75+
$o->setDigit(10);
76+
$o->setSecret('FOOBAR');
77+
$o->setSecretLength(16);
78+
$o->setTimeStep(60);
79+
80+
$this->assertEquals('FOOBAR', $o->getSecret());
81+
$this->assertEquals(10, $o->getDigit());
82+
$this->assertEquals(16, $o->getSecretLength());
83+
$this->assertEquals(60, $o->getTimeStep());
84+
}
85+
}

0 commit comments

Comments
 (0)