renamed form fields to work better with password managers; fixed bug where an invalid login requests were not being counted as login attempts; preserve username when using central auth

This commit is contained in:
2026-06-01 16:28:57 -04:00
parent 9114cfd96f
commit 0813323ac2
11 changed files with 136 additions and 103 deletions
+1
View File
@@ -15,3 +15,4 @@ twig:
teapot_message: '%env(TEAPOT_MESSAGE)%'
too_many_title: '%env(TOO_MANY_TITLE)%'
too_many_message: '%env(TOO_MANY_MESSAGE)%'
debug: '%env(SHELL_VERBOSITY)%'
+3
View File
@@ -51,6 +51,9 @@ parameters:
env(TOO_MANY_TITLE): 'Too many requests'
env(TOO_MANY_MESSAGE): 'Try again later'
# --- debug options ---
env(SHELL_VERBOSITY): '0' # set to 3 to log debug
# --- application variables ---
app.totp_uri: '%env(TOTP_URI)%'
app.cookie_ttl: '%env(COOKIE_TTL)%'
+3
View File
@@ -50,3 +50,6 @@
#TOO_MANY_TITLE='Too many requests'
#TOO_MANY_MESSAGE='Try again later'
# --- debug options ---
#SHELL_VERBOSITY=0 # set to "3" to log debug
+10 -10
View File
@@ -31,11 +31,11 @@ final class Payload {
public static function load(InputBag $input): ?Payload {
/* convert form data into real data */
if ($input->has('preauth_nonce') && $input->has('preauth_id') && $input->has('preauth_token')) {
if ($input->has('username') && $input->has('nonce') && $input->has('totp')) {
return Payload::create((object)[
'id' => $input->get('preauth_id'),
'nonce' => $input->get('preauth_nonce'),
'token' => $input->get('preauth_token'),
'id' => $input->get('username'),
'nonce' => $input->get('nonce'),
'token' => $input->get('totp'),
'json' => false,
]);
}
@@ -44,9 +44,9 @@ final class Payload {
public static function create(object $data): ?Payload {
/* if missing required fields id, nonce, or token */
if (strlen($data->id ?? '') < 1 ||
strlen($data->nonce ?? '') < 1 ||
strlen($data->token ?? '') < 1
if (strlen(trim($data->id ?? '')) < 1 ||
strlen(trim($data->nonce ?? '')) < 1 ||
strlen(trim($data->token ?? '')) < 1
) {
/* returns null as the input is invalid */
return null;
@@ -54,11 +54,11 @@ final class Payload {
/* all input is limited */
$payload = new Payload();
$payload->id = mb_substr($data->id, 0, 128);
$payload->nonce = mb_substr($data->nonce, 0, 128);
$payload->id = mb_substr(trim($data->id), 0, 128);
$payload->nonce = mb_substr(trim($data->nonce), 0, 128);
$payload->json = ($data->json ?? true);
$payload->scope = Scope::tryFrom($data->scope ?? '') ?? Scope::Cookie;
$payload->token = mb_substr($data->token, 0, 128);
$payload->token = mb_substr(trim($data->token), 0, 128);
return Payload::constrict($payload);
}
+1 -1
View File
@@ -40,7 +40,7 @@ final readonly class InterceptListener {
) {
/* host matches base-domain of auth, but not on auth subdomain, redirect */
$query = http_build_query(['return' => $event->getRequest()->getUri()]);
$event->setResponse(new Response('', Response::HTTP_TEMPORARY_REDIRECT,
$event->setResponse(new Response('', Response::HTTP_SEE_OTHER,
['Location' => "https://{$this->domainManager->getAuthSubdomain()}/?$query"]
));
} else {
+18 -12
View File
@@ -10,6 +10,7 @@ use App\Service\LoginManager;
use App\Trait\CookieNameTrait;
use App\Trait\HasLoggerTrait;
use App\Trait\MakeNonceTrait;
use App\Trait\StringTrait;
use Psr\Cache\InvalidArgumentException;
use Symfony\Component\DependencyInjection\Attribute\Target;
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
@@ -26,6 +27,7 @@ final readonly class LoginListener {
use CookieNameTrait;
use HasLoggerTrait;
use MakeNonceTrait;
use StringTrait;
private RateLimiterFactoryInterface $rateLimiter;
@@ -42,7 +44,9 @@ final readonly class LoginListener {
/** @throws InvalidArgumentException|LoaderError|RuntimeError|SyntaxError */
#[AsEventListener(priority: 66)]
public function onKernelRequest(RequestEvent $event): void {
$payload = null;
$payload = null;
$response = null;
if ($event->getRequest()->headers->has($this->headerName())) {
/* if request contains our "X-Preauth" header */
$data = $event->getRequest()->headers->get($this->headerName());
@@ -52,19 +56,20 @@ final readonly class LoginListener {
) {
/* if request is a POST to the auth-subdomain */
$payload = Payload::load($event->getRequest()->getPayload());
}
if ( ! $payload) {
/* user is not attempting to log in */
} else {
/* no login attempt detected */
return;
}
$response = $this->loginManager->checkToken($payload, $event->getRequest());
if ($payload) {
/* user sent a valid payload, check it */
$response = $this->loginManager->checkToken($payload, $event->getRequest());
/* token or backup-code authentication was successful */
if ($response) {
$event->setResponse($response);
return;
/* token or backup-code authentication was successful */
if ($response) {
$event->setResponse($response);
return;
}
}
/* login attempted but unsuccessful, log and block if needed */
@@ -72,7 +77,7 @@ final readonly class LoginListener {
$this->logger->debug("logging failure for: {$event->getRequest()->getClientIp()}");
$event->setResponse($this->makeFailedResponse($limitReached, $payload->json ?? true,
$event->getRequest()->getHost()
$event->getRequest()->getHost(), $this->makeCacheKey($payload ? $payload->id : '')
));
}
@@ -82,7 +87,7 @@ final readonly class LoginListener {
}
/** @throws InvalidArgumentException|RuntimeError|SyntaxError|LoaderError */
private function makeFailedResponse(bool $limited, bool $json, string $host): Response {
private function makeFailedResponse(bool $limited, bool $json, string $host, string $username): Response {
if ($limited) {
$status = $this->config->teapot() ? Response::HTTP_I_AM_A_TEAPOT
: Response::HTTP_TOO_MANY_REQUESTS;
@@ -96,6 +101,7 @@ final readonly class LoginListener {
'message' => $message,
'nonce' => $this->makeNonce(),
'post' => $this->domainManager->getAuthSubdomain() === $host,
'username' => $username,
];
if ($json) {
+8 -5
View File
@@ -40,12 +40,12 @@ final readonly class BackupCodeManager {
$codes = [];
for ($i = 0; $i < $count; $i++) {
/* output is alphanumeric string of given length */
$codes[] = str_pad(substr(base_convert(bin2hex(random_bytes($length)),
16, 36), 0, $length),
$length, '0', STR_PAD_LEFT);
$codes[] = strtolower(str_pad(substr(base_convert(bin2hex(
random_bytes($length)
), 16, 36), 0, $length), $length, '0', STR_PAD_LEFT));
}
$this->saveCodes($codes);
$this->logger->info("generated {$count} backup codes}");
$this->logger->info("generated {$count} backup codes");
return $codes;
}
@@ -67,7 +67,10 @@ final readonly class BackupCodeManager {
* @return bool true if the code is valid and unused
* @throws InvalidArgumentException */
public function verifyAndConsume(string $code): bool {
$backupItem = $this->sessionCache->getItem($this->makeCacheKey(strtolower("backup_$code")));
/* remove unallowed characters, since backup codes are case-insensitive alphanumeric */
$backupKey = 'backup_' . preg_replace('/[^a-z0-9]+/', '', strtolower($code));
$backupItem = $this->sessionCache->getItem($this->makeCacheKey($backupKey));
$this->logger->debug("checking backup code '{$backupKey}': " . ($backupItem->isHit() ? 'HIT & ' : 'miss & ') . ($backupItem->get() ? 'VALID' : 'invalid'));
if ($backupItem->isHit() && $backupItem->get()) {
$this->logger->debug("valid backup code");
/* mark backup code as spent */
+2 -3
View File
@@ -89,10 +89,9 @@ final readonly class LoginManager {
"{$request->query->get('return')}" :
"{$request->getPathInfo()}{$request->getQueryString()}";
/* when using central auth, force redirect to use GET method */
/* force redirect to use GET method (important when using central auth) */
$response->setContent($content)
->setStatusCode($this->domainManager->getAuthSubdomain() === $request->getHost() ?
Response::HTTP_SEE_OTHER : Response::HTTP_TEMPORARY_REDIRECT)
->setStatusCode(Response::HTTP_SEE_OTHER)
->headers->set('Location', $location);
$response->headers->set('Content-Type', $contentType);
}
+78 -62
View File
@@ -1,68 +1,84 @@
<script>
const form = document.getElementById('preauth-form');
const message = document.getElementById('preauth-message');
const body = document.getElementById('preauth-body');
const style = document.getElementById('preauth-style');
const form = document.getElementById('preauth-form');
const message = document.getElementById('preauth-message');
const body = document.getElementById('preauth-body');
const style = document.getElementById('preauth-style');
form.addEventListener('submit', (event) => {
event.preventDefault();
form.addEventListener('submit', (event) => {
event.preventDefault();
/* make base64url string containing our payload json object */
const data = btoa(JSON.stringify({
id: form.preauth_id.value,
token: form.preauth_token.value,
nonce: form.preauth_nonce.value,
json: true
})).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
{# make base64url string containing our payload json object #}
const data = btoa(JSON.stringify({
id: form.username.value?.trim() ?? '',
token: form.totp.value?.trim() ?? '',
nonce: form.nonce.value?.trim() ?? '',
json: true
})).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
/* send our request to the server */
fetch(window.location.href, {
method: 'GET',
headers: { 'X-Preauth': data },
}).then((response) => {
if (response.headers.has('Location')) {
/* follow redirect (not needed in most browsers) */
window.location.href = response.headers.get('Location');
} else if (response.headers.get('Content-Type') === 'application/json') {
/* got json, update the page */
response.json().then((content) => {
if (Object.hasOwn(content, 'message')) {
message.innerText = content.message;
}
if (Object.hasOwn(content, 'nonce')) {
form.preauth_nonce.value = content.nonce;
form.preauth_token.value = '';
form.preauth_token.focus();
}
}).catch((error) => {
console.log('failed to parse json from response');
console.log(error);
});
} else if (response.headers.get('Content-Type') === 'text/html') {
/* got HTML, replace the page */
response.text().then((html) => {
document.open();
document.write(html);
document.close();
}).catch((error) => {
console.log('failed to get html from response');
console.log(error);
});
} else { /* non-json, non-html, non-redirect response */
/* update the page, change style to plain text */
response.text().then((text) => {
body.innerText = text;
style.disabled = true;
body.style.whiteSpace = 'pre-wrap';
body.style.wordWrap = 'break-word';
}).catch((error) => {
console.log('failed to get text from response');
console.log(error);
});
}
}).catch((error) => {
console.log('failed to get response');
console.log(error);
});
{# send our request to the server #}
fetch(window.location.href, {
method: 'GET',
headers: { 'X-Preauth': data },
}).then((response) => {
{% if env.debug > 2 -%}
console.log(response);
{% endif -%}
if (response.headers.has('Location')) {
{# follow redirect (probably not needed) #}
{% if env.debug > 2 -%}
console.log('got redirect response');
{% endif -%}
window.location.href = response.headers.get('Location');
} else if (response.headers.get('Content-Type')?.toLowerCase().includes('application/json') ?? false) {
{# got json, update the page #}
{% if env.debug > 2 -%}
console.log('got json response');
{% endif -%}
response.json().then((content) => {
if (Object.hasOwn(content, 'message')) {
message.innerText = content.message;
}
if (Object.hasOwn(content, 'nonce')) {
form.nonce.value = content.nonce;
form.totp.value = '';
form.totp.focus();
}
}).catch((error) => {
console.log('failed to parse json from response');
console.log(error);
});
} else if (response.headers.get('Content-Type')?.toLowerCase().includes('text/html') ?? false) {
{# got html, replace the page #}
{% if env.debug > 2 -%}
console.log('got html response');
{% endif -%}
response.text().then((html) => {
document.open();
document.write(html);
document.close();
}).catch((error) => {
console.log('failed to get html from response');
console.log(error);
});
} else {
{# non-json, non-html, non-redirect response #}
{# update the page, change style to plain text #}
{% if env.debug > 2 -%}
console.log('got misc response');
{% endif -%}
response.text().then((text) => {
body.innerText = text;
style.disabled = true;
body.style.whiteSpace = 'pre-wrap';
body.style.wordWrap = 'break-word';
}).catch((error) => {
console.log('failed to get text from response');
console.log(error);
});
}
}).catch((error) => {
console.log('failed to get response');
console.log(error);
});
});
</script>
+1 -1
View File
@@ -4,7 +4,7 @@
<meta charset="utf-8">
<title>{{ env.title }}</title>
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1">
{{ include('_style.html.twig') }}
{{- include('_style.html.twig') -}}
</head>
<body id="preauth-body">
{% block content %}{% endblock %}
+11 -9
View File
@@ -3,15 +3,17 @@
{% block content %}
<h1>{{ env.title }}</h1>
<p id="preauth-message">{{ message|default }}</p>
<form id="preauth-form" {% if post ?? false %} method="post" {% endif %}>
<input id="preauth-nonce" type="hidden" name="preauth_nonce" value="{{ nonce }}">
<div class="right"><label for="preauth-id">{{ env.id_name }}:</label></div>
<div><input type="text" name="preauth_id" id="preauth-id"
autocomplete="username" required="required" autofocus="autofocus"></div>
<div class="right"><label for="preauth-token">{{ env.token_name }}:</label></div>
<div><input type="text" name="preauth_token" id="preauth-token"
autocomplete="one-time-code" required="required"></div>
<form id="preauth-form" {% if post ?? false -%} method="post" {%- endif %}>
<input id="nonce" type="hidden" name="nonce" value="{{ nonce }}">
<div class="right"><label for="username">{{ env.id_name }}:</label></div>
<div><input type="text" name="username" id="username" {% if username ?? false %}value="{{ username }}"{% endif %}
autocomplete="username" required="required" autofocus="autofocus"></div>
<div class="right"><label for="totp">{{ env.token_name }}:</label></div>
<div><input type="text" name="totp" id="totp"
autocomplete="one-time-code" required="required"></div>
<div class="center"><button type="submit">{{ env.submit_name }}</button></div>
</form>
{% if not post ?? false %}{{ include('_script.html.twig') }}{% endif %}
{% if not post ?? false %}
{{- include('_script.html.twig') -}}
{% endif %}
{% endblock %}