1 Commits
Author SHA1 Message Date
andrew 27394ae555 v0.6.0 optional lookup for totp by static password 2026-02-10 13:31:09 -05:00
6 changed files with 85 additions and 17 deletions
+2 -1
View File
@@ -30,9 +30,9 @@ parameters:
# could be useful if you have a system which does not handle cookies
env(IP_TTL): '0' # default disabled, time in seconds
# if desired, in addition to supporting a TOTP, you can set a static password
# TODO rely on checking enabled, instead of the secret directly throughout the code
env(STATIC_SECRET_ENABLED): '0' # boolean
env(STATIC_SECRET): '' # default disabled
env(LOOKUP_TOTP): '' # default disabled
# once blocked, do we respond with "I'm a teapot", false to use "Too many requests"
env(TEAPOT): '1' # boolean
@@ -63,6 +63,7 @@ parameters:
app.ip_ttl: '%env(IP_TTL)%'
app.static_secret_enabled: '%env(STATIC_SECRET_ENABLED)%'
app.static_secret: '%env(STATIC_SECRET)%'
app.lookup_totp: '%env(LOOKUP_TOTP)%'
app.teapot: '%env(TEAPOT)%'
app.error_message: '%env(ERROR_MESSAGE)%'
+5
View File
@@ -27,8 +27,12 @@
#IP_TTL=0 # default disabled, time in seconds
# if desired, in addition to supporting a TOTP, you can set a static password
# if you enable a static secret, you may choose to also enable a totp-lookup,
# which contains a special path, when successfully authenticated,
# instead of returning "hi <your-name>" it returns "code <totp-code>".
#STATIC_SECRET_ENABLED='0' # boolean, disabled by default
#STATIC_SECRET='' # default disabled
#LOOKUP_TOTP='' # default disabled
# once blocked, do we respond with "I'm a teapot", false to use "Too many requests"
#TEAPOT=true # default enabled, boolean
@@ -40,6 +44,7 @@
#ERROR_COLOR='#ffb16d' # apricot (light orange)
#ID_NAME='Session ID'
#TOKEN_NAME='Authentication Token'
PASSWORD_NAME='Authentication Password'
#SUBMIT_NAME='Submit'
#ERROR_MESSAGE='Unsuccessful login attempt'
# title and message to use on block page, if teapot is true
+7
View File
@@ -16,6 +16,7 @@ final readonly class ConfigBag {
private string $queryPrefix;
private string $totpUri;
private ?int $ipTtl;
private ?string $lookupTotp;
private ?string $staticSecret;
private bool $teapot;
private string $errorMessage;
@@ -33,6 +34,7 @@ final readonly class ConfigBag {
#[Autowire('%app.query_prefix%')] string $queryPrefix,
#[Autowire('%app.totp_uri%')] string $totpUri,
#[Autowire('%app.ip_ttl%')] ?int $ipTtl,
#[Autowire('%app.lookup_totp%')] ?string $lookupTotp,
#[Autowire('%app.static_secret_enabled%')] bool $staticSecretEnabled,
#[Autowire('%app.static_secret%')] ?string $staticSecret,
#[Autowire('%app.teapot%')] bool $teapot,
@@ -49,6 +51,7 @@ final readonly class ConfigBag {
$this->totpUri = $totpUri ?: $utilities->loadTotp();
$this->ipTtl = $ipTtl ?: null;
$this->staticSecret = $staticSecretEnabled ? ($staticSecret ?: null) : null;
$this->lookupTotp = $this->staticSecret ? ($lookupTotp ?: null) : null;
$this->teapot = $teapot;
$this->errorMessage = $errorMessage;
$this->teapotTitle = $teapotTitle;
@@ -87,6 +90,10 @@ final readonly class ConfigBag {
return $this->ipTtl;
}
public function lookupTotp(): ?string {
return $this->lookupTotp;
}
public function staticSecret(): ?string {
return $this->staticSecret;
}
+8 -16
View File
@@ -8,10 +8,9 @@ use App\Data\Payload;
use App\Enum\Scope;
use App\MonitorCacheKeys;
use App\Trait\CookieNameTrait;
use App\Trait\GetTotpTrait;
use App\Trait\MakeNonceTrait;
use App\Trait\StringTrait;
use OTPHP\Factory;
use OTPHP\TOTPInterface;
use Psr\Cache\CacheItemPoolInterface;
use Psr\Cache\InvalidArgumentException;
use Psr\Log\LoggerInterface;
@@ -31,19 +30,21 @@ final readonly class LoginListener {
use CookieNameTrait;
use MakeNonceTrait;
use StringTrait;
use GetTotpTrait;
private CacheItemPoolInterface $requestPool;
private CacheItemPoolInterface $sessionPool;
/** @throws InvalidArgumentException */
public function __construct(
private ConfigBag $config,
ConfigBag $config,
private Environment $twig,
CacheItemPoolInterface $noncePool,
CacheItemPoolInterface $requestPool,
CacheItemPoolInterface $sessionPool,
LoggerInterface $logger,
) {
$this->config = $config;
$this->requestPool = new MonitorCacheKeys($requestPool);
$this->sessionPool = new MonitorCacheKeys($sessionPool);
$this->noncePool = $noncePool;
@@ -98,7 +99,7 @@ final readonly class LoginListener {
if ($nonceItem->isHit() && $nonceItem->get()) {
/* mark nonce as spent */
$nonceItem->set(false); /* invalid */
$nonceItem->expiresAfter(static::NONCE_TTL); /* keep breifly */
$nonceItem->expiresAfter(LoginListener::NONCE_TTL); /* keep briefly */
$this->noncePool->save($nonceItem);
/* token authentication successful, grant access and set response */
@@ -159,14 +160,15 @@ final readonly class LoginListener {
if (($nonceItem->isHit() && $nonceItem->get()) || ! $nonceItem->isHit()) {
/* mark nonce as spent */
$nonceItem->set(false); /* invalid */
$nonceItem->expiresAfter(static::NONCE_TTL); /* keep breifly */
$nonceItem->expiresAfter(LoginListener::NONCE_TTL); /* keep briefly */
$this->noncePool->save($nonceItem);
/* password authentication successful, grant access and set response */
$cleanId = $this->makeCacheKey($payload->id);
$this->logger->debug("successful login for: $cleanId");
return new Response("hi $cleanId",
headers: ['Content-Type' => 'text/plain']
Response::HTTP_OK,
['Content-Type' => 'text/plain']
);
}
}
@@ -254,14 +256,4 @@ final readonly class LoginListener {
return new Response($content, $status, ["Content-Type" => $contentType]);
}
private function getTotp(): TOTPInterface {
$otp = Factory::loadFromProvisioningUri(
$this->config->totpUri(), $this->config->clock()
);
if ($otp instanceof TOTPInterface) {
return $otp;
}
throw new HttpException(500, 'Internal Server Exception');
}
}
+41
View File
@@ -0,0 +1,41 @@
<?php
namespace App\Listener;
use App\ConfigBag;
use App\Trait\GetTotpTrait;
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Event\ResponseEvent;
final readonly class LookupListener {
use GetTotpTrait;
public function __construct(
ConfigBag $config,
) {
$this->config = $config;
}
#[AsEventListener(priority: 44)]
public function onKernelResponse(ResponseEvent $event): void {
/* if you enable a static secret, you may choose to also enable
* a totp-lookup, which contains a special path, when successfully
* authenticated, instead of returning "hi <your-name>" it returns
* "code <totp-code>" */
if ($this->config->staticSecret() && $this->config->lookupTotp()) {
if (str_contains($event->getRequest()->getPathInfo(),
$this->config->lookupTotp()) &&
$event->getResponse()->getStatusCode() === Response::HTTP_OK
) {
$totp = $this->getTotp();
$next = $totp->at(time() + $totp->getPeriod());
$event->setResponse(new Response(
"next {$next}",
Response::HTTP_OK,
['Content-Type' => 'text/plain']
));
}
}
}
}
+22
View File
@@ -0,0 +1,22 @@
<?php
namespace App\Trait;
use App\ConfigBag;
use OTPHP\Factory;
use OTPHP\TOTPInterface;
use Symfony\Component\HttpKernel\Exception\HttpException;
trait GetTotpTrait {
protected readonly ConfigBag $config;
protected function getTotp(): TOTPInterface {
$otp = Factory::loadFromProvisioningUri(
$this->config->totpUri(), $this->config->clock()
);
if ($otp instanceof TOTPInterface) {
return $otp;
}
throw new HttpException(500, 'Internal Server Exception');
}
}