Merge pull request #2140 from unraid/chore/remove-t2fa-from-login

chore: remove t2fa from .login.php template
This commit is contained in:
tom mortensen
2025-04-08 17:23:08 -07:00
committed by GitHub

View File

@@ -2,10 +2,12 @@
// Included in login.php
// Only start a session to check if they have a cookie that looks like our session
$server_name = strtok($_SERVER['HTTP_HOST'],":");
$server_name = strtok($_SERVER['HTTP_HOST'], ":");
if (!empty($_COOKIE['unraid_'.md5($server_name)])) {
// Start the session so we can check if $_SESSION has data
if (session_status()==PHP_SESSION_NONE) session_start();
if (session_status() == PHP_SESSION_NONE) {
session_start();
}
// Check if the user is already logged in
if ($_SESSION && !empty($_SESSION['unraid_user'])) {
@@ -15,10 +17,11 @@ if (!empty($_COOKIE['unraid_'.md5($server_name)])) {
}
}
function readFromFile($file): string {
function readFromFile($file): string
{
$text = "";
if (file_exists($file) && filesize($file) > 0) {
$fp = fopen($file,"r");
$fp = fopen($file, "r");
if (flock($fp, LOCK_EX)) {
$text = fread($fp, filesize($file));
flock($fp, LOCK_UN);
@@ -28,8 +31,9 @@ function readFromFile($file): string {
return $text;
}
function appendToFile($file, $text): void {
$fp = fopen($file,"a");
function appendToFile($file, $text): void
{
$fp = fopen($file, "a");
if (flock($fp, LOCK_EX)) {
fwrite($fp, $text);
fflush($fp);
@@ -38,8 +42,9 @@ function appendToFile($file, $text): void {
}
}
function writeToFile($file, $text): void {
$fp = fopen($file,"w");
function writeToFile($file, $text): void
{
$fp = fopen($file, "w");
if (flock($fp, LOCK_EX)) {
fwrite($fp, $text);
fflush($fp);
@@ -56,7 +61,8 @@ function isValidTimeStamp($timestamp)
&& ($timestamp >= ~PHP_INT_MAX);
}
function cleanupFails(string $failFile, int $time): int {
function cleanupFails(string $failFile, int $time): int
{
global $cooldown;
// Read existing fails
@@ -67,8 +73,8 @@ function cleanupFails(string $failFile, int $time): int {
// Remove entries older than $cooldown minutes, and entries that are not timestamps
$updateFails = false;
foreach ((array) $fails as $key => $value) {
if ( !isValidTimeStamp($value) || ($time - $value > $cooldown) || ($value > $time) ) {
unset ($fails[$key]);
if (!isValidTimeStamp($value) || ($time - $value > $cooldown) || ($value > $time)) {
unset($fails[$key]);
$updateFails = true;
}
}
@@ -81,94 +87,19 @@ function cleanupFails(string $failFile, int $time): int {
return count($fails);
}
function verifyUsernamePassword(string $username, string $password): bool {
if ($username != "root") return false;
function verifyUsernamePassword(string $username, string $password): bool
{
if ($username != "root") {
return false;
}
$output = exec("/usr/bin/getent shadow $username");
if ($output === false) return false;
if ($output === false) {
return false;
}
$credentials = explode(":", $output);
return password_verify($password, $credentials[1]);
}
function verifyTwoFactorToken(string $username, string $token): bool {
try {
// Create curl client
$curlClient = curl_init();
curl_setopt($curlClient, CURLOPT_HEADER, true);
curl_setopt($curlClient, CURLOPT_RETURNTRANSFER, true);
curl_setopt($curlClient, CURLOPT_UNIX_SOCKET_PATH, '/var/run/unraid-api.sock');
curl_setopt($curlClient, CURLOPT_URL, 'http://unixsocket/verify');
curl_setopt($curlClient, CURLOPT_BUFFERSIZE, 256);
curl_setopt($curlClient, CURLOPT_TIMEOUT, 5);
curl_setopt($curlClient, CURLOPT_HTTPHEADER, array('Content-Type:application/json', 'Origin: /var/run/unraid-notifications.sock'));
curl_setopt($curlClient, CURLOPT_POSTFIELDS, json_encode([
'username' => $username,
'token' => $token
]));
// Send the request
curl_exec($curlClient);
// Get the http status code
$httpCode = curl_getinfo($curlClient, CURLINFO_HTTP_CODE);
// Close the connection
curl_close($curlClient);
// Error
// This should accept 200 or 204 status codes
if ($httpCode !== 200 && $httpCode !== 204) {
// Log error to syslog
my_logger("2FA code for {$username} is invalid, blocking access!");
return false;
}
// Log success to syslog
my_logger("2FA code for {$username} is valid, allowing login!");
// Success
return true;
} catch (Exception $exception) {
// Error
return false;
}
}
// Check if a haystack ends in a needle
function endsWith($haystack, $needle): bool {
return substr_compare($haystack, $needle, -strlen($needle)) === 0;
}
// Check if we're accessing this via a wildcard cert
function isWildcardCert(): bool {
global $server_name;
return endsWith($server_name, '.myunraid.net');
}
// Check if we're accessing this locally via the expected myunraid.net url
function isLocalAccess(): bool {
global $nginx, $server_name;
return isWildcardCert() && $nginx['NGINX_LANFQDN'] === $server_name;
}
// Check if we're accessing this remotely via the expected myunraid.net url
function isRemoteAccess(): bool {
global $nginx, $server_name;
return isWildcardCert() && $nginx['NGINX_WANFQDN'] === $server_name;
}
// Check if 2fa is enabled for local (requires USE_SSL to be "auto" so no alternate urls can access the server)
function isLocalTwoFactorEnabled(): bool {
global $nginx, $my_servers;
return $nginx['NGINX_USESSL'] === "auto" && ($my_servers['local']['2Fa']??'') === 'yes';
}
// Check if 2fa is enabled for remote
function isRemoteTwoFactorEnabled(): bool {
global $my_servers;
return ($my_servers['remote']['2Fa']??'') === 'yes';
}
// Load configs into memory
$my_servers = @parse_ini_file('/boot/config/plugins/dynamix.my.servers/myservers.cfg', true);
$nginx = @parse_ini_file('/var/local/emhttp/nginx.ini');
@@ -180,38 +111,34 @@ $remote_addr = $_SERVER['REMOTE_ADDR'] ?? "unknown";
$failFile = "/var/log/pwfail/{$remote_addr}";
// Get the credentials
$username = $_POST['username']??'';
$password = $_POST['password']??'';
$token = $_REQUEST['token']??'';
// Check if we need 2fa
$twoFactorRequired = (isLocalAccess() && isLocalTwoFactorEnabled()) || (isRemoteAccess() && isRemoteTwoFactorEnabled());
$username = $_POST['username'] ?? '';
$password = $_POST['password'] ?? '';
// If we have a username + password combo attempt to login
if (!empty($username) && !empty($password)) {
try {
// Bail if we're missing the 2FA token and we expect one
if (isWildcardCert() && $twoFactorRequired && empty($token)) throw new Exception(_('No 2FA token detected'));
// Read existing fails, cleanup expired ones
$time = time();
$failCount = cleanupFails($failFile, $time);
// Check if we're limited
if ($failCount >= $maxFails) {
if ($failCount == $maxFails) my_logger("Ignoring login attempts for {$username} from {$remote_addr}");
if ($failCount == $maxFails) {
my_logger("Ignoring login attempts for {$username} from {$remote_addr}");
}
throw new Exception(_('Too many invalid login attempts'));
}
// Bail if username + password combo doesn't work
if (!verifyUsernamePassword($username, $password)) throw new Exception(_('Invalid username or password'));
// Bail if we need a token but it's invalid
if (isWildcardCert() && $twoFactorRequired && !verifyTwoFactorToken($username, $token)) throw new Exception(_('Invalid 2FA token'));
if (!verifyUsernamePassword($username, $password)) {
throw new Exception(_('Invalid username or password'));
}
// Successful login, start session
@unlink($failFile);
if (session_status()==PHP_SESSION_NONE) session_start();
if (session_status() == PHP_SESSION_NONE) {
session_start();
}
$_SESSION['unraid_login'] = time();
$_SESSION['unraid_user'] = $username;
session_regenerate_id(true);
@@ -274,8 +201,8 @@ $theme_dark = in_array($display['theme'], ['black', 'gray']);
/
/************************/
body {
background: <?=$theme_dark?'#1C1B1B':'#F2F2F2'?>;
color: <?=$theme_dark?'#fff':'#1c1b1b'?>;
background: <?=$theme_dark ? '#1C1B1B' : '#F2F2F2'?>;
color: <?=$theme_dark ? '#fff' : '#1c1b1b'?>;
font-family: clear-sans, sans-serif;
font-size: .875rem;
padding: 0;
@@ -359,7 +286,7 @@ $theme_dark = in_array($display['theme'], ['black', 'gray']);
width: 500px;
margin: 6rem auto;
border-radius: 10px;
background: <?=$theme_dark?'#2B2A29':'#fff'?>;
background: <?=$theme_dark ? '#2B2A29' : '#fff'?>;
}
#login::after {
content: "";
@@ -392,7 +319,7 @@ $theme_dark = in_array($display['theme'], ['black', 'gray']);
}
#login .error {
color: red;
margin-top: -20px;
margin-top: 1rem;
}
#login .content {
padding: 2rem;
@@ -451,7 +378,7 @@ $theme_dark = in_array($display['theme'], ['black', 'gray']);
/************************/
@media (max-width: 500px) {
body {
background: <?=$theme_dark?'#2B2A29':'#fff'?>;
background: <?=$theme_dark ? '#2B2A29' : '#fff'?>;
}
[type=email], [type=number], [type=password], [type=search], [type=tel], [type=text], [type=url], textarea {
font-size: 16px; /* This prevents the mobile browser from zooming in on the input-field. */
@@ -483,7 +410,7 @@ $theme_dark = in_array($display['theme'], ['black', 'gray']);
<?=htmlspecialchars($var['NAME'])?>
</h1>
<h2>
<?=htmlspecialchars($var['COMMENT'])?>
<?=htmlspecialchars($var['COMMENT'])?>
</h2>
<div class="case">
@@ -497,82 +424,38 @@ $theme_dark = in_array($display['theme'], ['black', 'gray']);
</div>
<div class="form">
<form class="js-removeTimeout" action="/login" method="POST">
<? if (($twoFactorRequired && !empty($token)) || !$twoFactorRequired) { ?>
<p>
<input name="username" type="text" placeholder="<?=_('Username')?>" autocapitalize="none" autocomplete="off" spellcheck="false" autofocus required>
<input name="password" type="password" placeholder="<?=_('Password')?>" required>
<? if ($twoFactorRequired && !empty($token)) { ?>
<input name="token" type="hidden" value="<?= $token ?>">
<? } ?>
</p>
<? if ($error) echo "<p class='error'>$error</p>"; ?>
<p>
<button type="submit" class="button button--small"><?=_('Login')?></button>
</p>
<? } else { ?>
<? if ($error) { ?>
<div>
<p class="error" style="padding-top:10px;"><?= $error ?></p>
</div>
<? } else { ?>
<div>
<p class="error" style="padding-top:10px;" title="<?= _('Please access this server via the My Servers Dashboard') ?>"><?= _('No 2FA token detected') ?></p>
</div>
<? } ?>
<div>
<a href="https://forums.unraid.net/my-servers/" class="button button--small" title="<?=_('Go to My Servers Dashboard')?>"><?=_('Go to My Servers Dashboard')?></a>
</div>
<? } ?>
<script type="text/javascript">
document.cookie = "cookietest=1";
cookieEnabled = document.cookie.indexOf("cookietest=")!=-1;
document.cookie = "cookietest=1; expires=Thu, 01-Jan-1970 00:00:01 GMT";
if (!cookieEnabled) {
const errorElement = document.createElement('p');
errorElement.classList.add('error');
errorElement.textContent = "<?=_('Please enable cookies to use the Unraid webGUI')?>";
document.body.textContent = '';
document.body.appendChild(errorElement);
}
</script>
<form action="/login" method="POST">
<p>
<input name="username" type="text" placeholder="<?=_('Username')?>" autocapitalize="none" autocomplete="off" spellcheck="false" autofocus required>
<input name="password" type="password" placeholder="<?=_('Password')?>" required>
</p>
<p>
<button type="submit" class="button button--small"><?=_('Login')?></button>
</p>
<?php if ($error) { ?>
<p class="error"><?= $error ?></p>
<?php } ?>
</form>
<? if (($twoFactorRequired && !empty($token)) || !$twoFactorRequired) { ?>
<div class="js-addTimeout hidden">
<p class="error" style="padding-top:10px;"><?=_('Transparent 2FA Token timed out')?></p>
<a href="https://forums.unraid.net/my-servers/" class="button button--small" title="<?=_('Go to My Servers Dashboard')?>"><?=_('Go to My Servers Dashboard')?></a>
</div>
<? } ?>
</div>
<? if (($twoFactorRequired && !empty($token)) || !$twoFactorRequired) { ?>
<p class="js-removeTimeout"><a href="https://docs.unraid.net/go/lost-root-password/" target="_blank"><?=_('Password recovery')?></a></p>
<? } ?>
<a href="https://docs.unraid.net/go/lost-root-password/" target="_blank"><?=_('Password recovery')?></a>
</div>
</section>
<? if ($twoFactorRequired && !empty($token)) { ?>
<script type="text/javascript">
const $elsToRemove = document.querySelectorAll('.js-removeTimeout');
const $elsToShow = document.querySelectorAll('.js-addTimeout');
/**
* A user can manually refresh the page or submit with the wrong username/password
* the t2fa token will be re-used on these page refreshes. We need to keep track of the timeout across potential page
* loads rather than setting the timer with a fresh timeout each page load
*/
const tokenName = '<?=$token?>'.slice(-20);
const ts = Date.now();
const timeoutStarted = sessionStorage.getItem(tokenName) ? Number(sessionStorage.getItem(tokenName)) : ts;
const timeoutDiff = ts - timeoutStarted; // current timestamp minus timestamp when token first set
const timeoutMS = 297000 - timeoutDiff; // 5 minutes minus 3seconds or (5*60)*1000ms - 3000ms = 297000
sessionStorage.setItem(tokenName, timeoutStarted);
const tokenTimeout = setTimeout(() => {
$elsToRemove.forEach(z => z.remove()); // remove elements
$elsToShow.forEach(z => z.classList.remove('hidden')); // add elements
}, timeoutMS); // if timeoutMS is negative value the timeout will trigger immediately
</script>
<? } ?>
<script type="text/javascript">
document.cookie = "cookietest=1";
cookieEnabled = document.cookie.indexOf("cookietest=")!=-1;
document.cookie = "cookietest=1; expires=Thu, 01-Jan-1970 00:00:01 GMT";
if (!cookieEnabled) {
const formParentElement = document.querySelector('.form');
const errorElement = document.createElement('p');
errorElement.classList.add('error');
errorElement.textContent = "<?=_('Please enable cookies to use the Unraid webGUI')?>";
document.body.textContent = '';
document.body.appendChild(errorElement);
}
</script>
</body>
</html>