Skip to content

Commit 28cae2e

Browse files
authored
Merge pull request #31 from dotkernel/totp-code
Totp integration code
2 parents bf8e4de + e35951b commit 28cae2e

20 files changed

+1093
-0
lines changed
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
GetEnableTotpFormHandler::class => AttributedServiceFactory::class,
2+
PostEnableTotpHandler::class => AttributedServiceFactory::class,
3+
GetDisableTotpFormHandler::class => AttributedServiceFactory::class,
4+
PostDisableTotpHandler::class => AttributedServiceFactory::class,
5+
PostValidateTotpHandler::class => AttributedServiceFactory::class,
6+
GetTotpHandler::class => AttributedServiceFactory::class,
7+
GetRecoveryFormHandler::class => AttributedServiceFactory::class,
8+
PostValidateRecoveryHandler::class => AttributedServiceFactory::class,
9+
10+
TotpForm::class => ElementFactory::class,
11+
RecoveryForm::class => ElementFactory::class,
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
$app->pipe(TotpMiddleware::class);
2+
$app->pipe(CancelUrlMiddleware::class);
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
->get('/enable-totp-form', GetEnableTotpFormHandler::class, 'admin::enable-totp-form')
2+
->post('/enable-totp', PostEnableTotpHandler::class, 'admin::enable-totp')
3+
->get('/disable-totp-form', GetDisableTotpFormHandler::class, 'admin::disable-totp-form')
4+
->post('/disable-totp', PostDisableTotpHandler::class, 'admin::disable-totp')
5+
->get('/validate-totp-form', GetTotpHandler::class, 'admin::validate-totp-form')
6+
->post('/validate-totp', PostValidateTotpHandler::class, 'admin::validate-totp')
7+
->get('/recovery-form', GetRecoveryFormHandler::class, 'admin::recovery-form')
8+
->post('/validate-recovery', PostValidateRecoveryHandler::class, 'admin::validate-recovery')
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
<div class="bgc-white p-20 bd">
2+
<h6 class="c-grey-900">TOTP Setup</h6>
3+
{% if isTotpEnabled %}
4+
<h6 class="c-grey-900">TOTP is enabled</h6>
5+
<div class="d-flex justify-content-end">
6+
<a href="{{ path('admin::disable-totp-form') }}"
7+
class="btn btn-primary btn-color btn-sm">
8+
Disable TOTP
9+
</a>
10+
</div>
11+
{% else %}
12+
<h6 class="c-grey-900">TOTP is disabled</h6>
13+
<div class="d-flex justify-content-end">
14+
<a href="{{ path('admin::enable-totp-form') }}"
15+
class="btn btn-primary btn-color btn-sm">
16+
Enable TOTP
17+
</a>
18+
</div>
19+
{% endif %}
20+
</div>
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
use Core\Admin\Entity\Admin;
6+
use Core\Admin\Entity\AdminIdentity;
7+
8+
return [
9+
'dot_totp' => [
10+
'identity_class_map' => [
11+
AdminIdentity::class => Admin::class,
12+
],
13+
'totp_required_routes' => [
14+
'page::components' => true,
15+
],
16+
'options' => [
17+
// Time step in seconds
18+
'period' => 30,
19+
// Number of digits in the TOTP code
20+
'digits' => 6,
21+
// Hashing algorithm used to generate the code
22+
'algorithm' => 'sha1',
23+
],
24+
'provision_uri_config' => [
25+
'issuer' => 'DK-Admin',
26+
],
27+
],
28+
];
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Admin\Admin\Form;
6+
7+
use Admin\Admin\InputFilter\RecoveryInputFilter;
8+
use Admin\App\Form\AbstractForm;
9+
use Dot\DependencyInjection\Attribute\Inject;
10+
use Laminas\Form\Element\Csrf;
11+
use Laminas\Form\Element\Submit;
12+
use Laminas\Form\Element\Text;
13+
use Laminas\Form\Exception\ExceptionInterface;
14+
use Laminas\Session\Container;
15+
use Mezzio\Router\RouterInterface;
16+
17+
/**
18+
* @phpstan-import-type RecoveryDataType from RecoveryInputFilter
19+
* @extends AbstractForm<RecoveryDataType>
20+
*/
21+
class RecoveryForm extends AbstractForm
22+
{
23+
/**
24+
* @param array<non-empty-string, mixed> $options
25+
* @throws ExceptionInterface
26+
*/
27+
#[Inject(
28+
RouterInterface::class,
29+
)]
30+
public function __construct(?string $name = null, array $options = [])
31+
{
32+
parent::__construct($name, $options);
33+
34+
$this->init();
35+
36+
$this->setAttribute('id', 'recovery-form');
37+
$this->setAttribute('method', 'post');
38+
39+
$this->inputFilter = new RecoveryInputFilter();
40+
$this->inputFilter->init();
41+
}
42+
43+
/**
44+
* @throws ExceptionInterface
45+
*/
46+
public function init(): void
47+
{
48+
$this->add(
49+
(new Text('recoveryCode'))
50+
->setLabel('Recovery Code')
51+
->setAttribute('class', 'form-control')
52+
->setAttribute('minlength', 11)
53+
->setAttribute('maxlength', 11)
54+
->setAttribute('pattern', '[A-Za-z0-9]{5}-[A-Za-z0-9]{5}')
55+
->setAttribute('required', true)
56+
->setAttribute('autofocus', true)
57+
);
58+
59+
$this->add(
60+
(new Csrf('recoveryCsrf'))
61+
->setOptions([
62+
'csrf_options' => [
63+
'timeout' => 3600,
64+
'session' => new Container(),
65+
],
66+
])
67+
->setAttribute('required', true)
68+
);
69+
70+
$this->add(
71+
(new Submit('submit'))
72+
->setAttribute('type', 'submit')
73+
->setAttribute('value', 'Verify Code')
74+
->setAttribute('class', 'btn btn-primary mt-2')
75+
);
76+
}
77+
}
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Admin\Admin\Form;
6+
7+
use Admin\Admin\InputFilter\TotpInputFilter;
8+
use Admin\App\Form\AbstractForm;
9+
use Dot\DependencyInjection\Attribute\Inject;
10+
use Laminas\Form\Element\Csrf;
11+
use Laminas\Form\Element\Submit;
12+
use Laminas\Form\Element\Text;
13+
use Laminas\Form\Exception\ExceptionInterface;
14+
use Laminas\Session\Container;
15+
use Mezzio\Router\RouterInterface;
16+
17+
/**
18+
* @phpstan-import-type TotpDataType from TotpInputFilter
19+
* @extends AbstractForm<TotpDataType>
20+
*/
21+
class TotpForm extends AbstractForm
22+
{
23+
/**
24+
* @param array<non-empty-string, mixed> $options
25+
* @throws ExceptionInterface
26+
*/
27+
#[Inject(
28+
RouterInterface::class,
29+
)]
30+
public function __construct(?string $name = null, array $options = [])
31+
{
32+
parent::__construct($name, $options);
33+
34+
$this->init();
35+
36+
$this->setAttribute('id', 'enable-totp-form');
37+
$this->setAttribute('method', 'post');
38+
$this->setAttribute('title', 'TOTP Authentication Setup');
39+
40+
$this->inputFilter = new TotpInputFilter();
41+
$this->inputFilter->init();
42+
}
43+
44+
/**
45+
* @throws ExceptionInterface
46+
*/
47+
public function init(): void
48+
{
49+
$this->add(
50+
(new Text('code'))
51+
->setLabel('Authentication Code')
52+
->setAttribute('class', 'form-control')
53+
->setAttribute('maxlength', 6)
54+
->setAttribute('pattern', '\d{6}')
55+
->setAttribute('required', true)
56+
->setAttribute('autofocus', true)
57+
);
58+
59+
$this->add(
60+
(new Csrf('totpCsrf'))
61+
->setOptions([
62+
'csrf_options' => [
63+
'timeout' => 3600,
64+
'session' => new Container(),
65+
],
66+
])
67+
->setAttribute('required', true)
68+
);
69+
70+
$this->add(
71+
(new Submit('submit'))
72+
->setAttribute('type', 'submit')
73+
->setAttribute('value', 'Verify Code')
74+
->setAttribute('class', 'btn btn-primary mt-2')
75+
);
76+
}
77+
}
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Admin\Admin\Handler\Account;
6+
7+
use Admin\Admin\Form\TotpForm;
8+
use Dot\DependencyInjection\Attribute\Inject;
9+
use Laminas\Authentication\AuthenticationService;
10+
use Laminas\Diactoros\Response\EmptyResponse;
11+
use Laminas\Diactoros\Response\HtmlResponse;
12+
use Mezzio\Router\RouterInterface;
13+
use Mezzio\Template\TemplateRendererInterface;
14+
use Psr\Http\Message\ResponseInterface;
15+
use Psr\Http\Message\ServerRequestInterface;
16+
use Psr\Http\Server\RequestHandlerInterface;
17+
18+
class GetDisableTotpFormHandler implements RequestHandlerInterface
19+
{
20+
#[Inject(
21+
TemplateRendererInterface::class,
22+
TotpForm::class,
23+
RouterInterface::class,
24+
AuthenticationService::class,
25+
)]
26+
public function __construct(
27+
protected TemplateRendererInterface $template,
28+
protected TotpForm $totpForm,
29+
protected RouterInterface $router,
30+
protected AuthenticationService $authenticationService,
31+
) {
32+
}
33+
34+
public function handle(ServerRequestInterface $request): ResponseInterface|EmptyResponse|HtmlResponse
35+
{
36+
$this->totpForm
37+
->setAttribute('action', $this->router->generateUri('admin::disable-totp'));
38+
39+
return new HtmlResponse(
40+
$this->template->render('admin::validate-totp-form', [
41+
'totpForm' => $this->totpForm->prepare(),
42+
'cancelUrl' => $this->router->generateUri('admin::edit-account'),
43+
'error' => null,
44+
])
45+
);
46+
}
47+
}
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Admin\Admin\Handler\Account;
6+
7+
use Admin\Admin\Form\TotpForm;
8+
use Dot\DependencyInjection\Attribute\Inject;
9+
use Dot\FlashMessenger\FlashMessengerInterface;
10+
use Dot\Totp\Totp;
11+
use Laminas\Authentication\AuthenticationService;
12+
use Laminas\Authentication\Exception\ExceptionInterface;
13+
use Laminas\Diactoros\Response\EmptyResponse;
14+
use Laminas\Diactoros\Response\HtmlResponse;
15+
use Mezzio\Router\RouterInterface;
16+
use Mezzio\Template\TemplateRendererInterface;
17+
use Psr\Http\Message\ResponseInterface;
18+
use Psr\Http\Message\ServerRequestInterface;
19+
use Psr\Http\Server\RequestHandlerInterface;
20+
use Random\RandomException;
21+
22+
use function time;
23+
24+
class GetEnableTotpFormHandler implements RequestHandlerInterface
25+
{
26+
private const int SECRET_MAX_AGE = 600;
27+
28+
/**
29+
* @param array{label: string, issuer: string} $provisioningUri
30+
*/
31+
#[Inject(
32+
Totp::class,
33+
AuthenticationService::class,
34+
FlashMessengerInterface::class,
35+
TemplateRendererInterface::class,
36+
TotpForm::class,
37+
RouterInterface::class,
38+
'config.dot_totp.provision_uri_config'
39+
)]
40+
public function __construct(
41+
protected Totp $totpService,
42+
protected AuthenticationService $authenticationService,
43+
protected FlashMessengerInterface $messenger,
44+
protected TemplateRendererInterface $template,
45+
protected TotpForm $totpForm,
46+
protected RouterInterface $router,
47+
protected array $provisioningUri
48+
) {
49+
}
50+
51+
/**
52+
* @throws ExceptionInterface
53+
* @throws RandomException
54+
*/
55+
public function handle(ServerRequestInterface $request): ResponseInterface|EmptyResponse|HtmlResponse
56+
{
57+
$storage = $this->authenticationService->getStorage()->read();
58+
59+
if (
60+
empty($storage->pendingSecret) ||
61+
empty($storage->secretTimestamp) ||
62+
(time() - $storage->secretTimestamp) > self::SECRET_MAX_AGE
63+
) {
64+
$storage->pendingSecret = $this->totpService->generateSecretBase32();
65+
$storage->secretTimestamp = time();
66+
$this->authenticationService->getStorage()->write($storage);
67+
}
68+
69+
$uri = $this->totpService->getProvisioningUri(
70+
$storage->getIdentity(),
71+
$this->provisioningUri['issuer'],
72+
$storage->pendingSecret
73+
);
74+
75+
$qrSvg = $this->totpService->generateInlineSvgQr($uri);
76+
$storage->totp_verified = false;
77+
78+
if (isset($storage->recovery_auth) && $storage->recovery_auth) {
79+
$this->totpForm->setAttribute('title', 'Reconfigure Two-Factor Authentication');
80+
}
81+
82+
$this->totpForm->setAttribute('action', $this->router->generateUri('admin::enable-totp'));
83+
84+
return new HtmlResponse(
85+
$this->template->render('admin::validate-totp-form', [
86+
'qrSvg' => $qrSvg,
87+
'cancelUrl' => $request->getAttribute('cancelUrl'),
88+
'totpForm' => $this->totpForm->prepare(),
89+
'error' => null,
90+
])
91+
);
92+
}
93+
}

0 commit comments

Comments
 (0)