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:
@@ -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)%'
|
||||
|
||||
@@ -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)%'
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
Reference in New Issue
Block a user