mirror of
https://github.com/OPSnet/Gazelle.git
synced 2026-01-16 18:04:34 -05:00
record ip when an MFA token is burnt
This commit is contained in:
@@ -2,8 +2,6 @@
|
||||
|
||||
namespace Gazelle;
|
||||
|
||||
use Gazelle\Enum\UserAuditEvent;
|
||||
|
||||
class Login extends Base {
|
||||
final public const NO_ERROR = 0;
|
||||
final public const ERR_CREDENTIALS = 1;
|
||||
@@ -14,7 +12,7 @@ class Login extends Base {
|
||||
protected bool $persistent = false;
|
||||
protected int $userId = 0;
|
||||
protected string $password;
|
||||
protected string $twofa;
|
||||
protected string $mfa;
|
||||
protected string $username;
|
||||
protected LoginWatch $watch;
|
||||
|
||||
@@ -43,13 +41,13 @@ class Login extends Base {
|
||||
string $password,
|
||||
LoginWatch $watch,
|
||||
bool $persistent = false,
|
||||
string $twofa = '',
|
||||
string $mfa = '',
|
||||
): ?User {
|
||||
$this->username = trim($username);
|
||||
$this->password = $password;
|
||||
$this->watch = $watch;
|
||||
$this->persistent = $persistent;
|
||||
$this->twofa = trim($twofa);
|
||||
$this->mfa = trim($mfa);
|
||||
|
||||
$begin = microtime(true);
|
||||
$user = $this->attemptLogin();
|
||||
@@ -89,7 +87,7 @@ class Login extends Base {
|
||||
);
|
||||
}
|
||||
}
|
||||
usleep((int)(600000 - (microtime(true) - $begin)));
|
||||
usleep((int)(LOGIN_SLEEP_USEC - (microtime(true) - $begin)));
|
||||
return $user;
|
||||
}
|
||||
|
||||
@@ -118,12 +116,12 @@ class Login extends Base {
|
||||
}
|
||||
|
||||
// password checks out, if they have 2FA, does that check out?
|
||||
$mfa = $user->MFA();
|
||||
$userMfa = $user->MFA();
|
||||
if (
|
||||
$mfa->enabled() && !(
|
||||
$this->twofa && $mfa->verify($this->twofa)
|
||||
$userMfa->enabled() && !(
|
||||
$this->mfa && $userMfa->verify($this->mfa)
|
||||
)
|
||||
|| !$mfa->enabled() && $this->twofa
|
||||
|| !$userMfa->enabled() && $this->mfa
|
||||
) {
|
||||
$this->error = self::ERR_CREDENTIALS;
|
||||
return null;
|
||||
@@ -140,7 +138,7 @@ class Login extends Base {
|
||||
$userMan->disableUserList(
|
||||
new Tracker(),
|
||||
[$user->id],
|
||||
UserAuditEvent::activity,
|
||||
Enum\UserAuditEvent::activity,
|
||||
"Logged in via Tor ($ipaddr)",
|
||||
Manager\User::DISABLE_TOR
|
||||
);
|
||||
|
||||
@@ -110,7 +110,7 @@ class Ban extends \Gazelle\Base {
|
||||
}
|
||||
|
||||
public function findByIp(string $ip): ?\Gazelle\Ban {
|
||||
$banId = $this->pg()->scalar("
|
||||
$banId = (int)$this->pg()->scalar("
|
||||
select id_ip_ban from ip_ban where ip && ?::inet
|
||||
", $ip
|
||||
);
|
||||
|
||||
@@ -6,7 +6,7 @@ use Gazelle\Enum\UserStatus;
|
||||
use Gazelle\Util\SortableTableHeader;
|
||||
|
||||
class Stylesheet extends \Gazelle\Base {
|
||||
final protected const CACHE_KEY = 'csslist2';
|
||||
final public const CACHE_KEY = 'csslist2';
|
||||
|
||||
protected array $info;
|
||||
|
||||
|
||||
@@ -86,7 +86,10 @@ class MultiFactorAuth extends \Gazelle\BaseUser {
|
||||
&& $userToken->type() === UserTokenType::mfa
|
||||
&& $userToken->consume()
|
||||
) {
|
||||
$this->user->auditTrail()->addEvent(UserAuditEvent::mfa, "used recovery token $token");
|
||||
$this->user->auditTrail()->addEvent(
|
||||
UserAuditEvent::mfa,
|
||||
"used recovery token $token from {$this->user->requestContext()->remoteAddr()}"
|
||||
);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
|
||||
@@ -622,6 +622,9 @@ defined('LOGIN_ATTEMPT_BACKOFF') or define('LOGIN_ATTEMPT_BACKOFF', [
|
||||
86400 * 7,
|
||||
]);
|
||||
|
||||
// Login sleep time to prevent timing attacks
|
||||
defined('LOGIN_SLEEP_USEC') or define('LOGIN_SLEEP_USEC', 600000);
|
||||
|
||||
// Releases and collages with these tags are hidden by default
|
||||
defined('HIDDEN_TAGS') or define('HIDDEN_TAGS', [0]);
|
||||
|
||||
|
||||
@@ -5,6 +5,11 @@
|
||||
bootstrap="phpunit-autoload.php"
|
||||
cacheDirectory="../cache/phpunit"
|
||||
>
|
||||
<php>
|
||||
<const name="IMAGE_CACHE_HOST" value="http://image.host" />
|
||||
<const name="IMAGE_CACHE_SECRET" value="phpunit" />
|
||||
<const name="LOGIN_SLEEP_USEC" value="100" />
|
||||
</php>
|
||||
<testsuites>
|
||||
<testsuite name="unit">
|
||||
<directory>../tests/phpunit</directory>
|
||||
@@ -20,8 +25,4 @@
|
||||
</include>
|
||||
<exclude/>
|
||||
</source>
|
||||
<php>
|
||||
<const name="IMAGE_CACHE_HOST" value="http://image.host" />
|
||||
<const name="IMAGE_CACHE_SECRET" value="phpunit" />
|
||||
</php>
|
||||
</phpunit>
|
||||
|
||||
@@ -19,7 +19,7 @@ if (!empty($_POST['username']) && !empty($_POST['password'])) {
|
||||
username: $_POST['username'],
|
||||
password: $_POST['password'],
|
||||
watch: $watch,
|
||||
twofa: $_POST['twofa'] ?? '',
|
||||
mfa: $_POST['twofa'] ?? '',
|
||||
persistent: isset($_POST['keeplogged']),
|
||||
);
|
||||
|
||||
|
||||
@@ -61,21 +61,54 @@ class UserCreateTest extends TestCase {
|
||||
$watch = new LoginWatch($login->requestContext()->remoteAddr());
|
||||
$watch->clearAttempts();
|
||||
|
||||
$result = $login->login($this->user->username(), 'not-the-password!', $watch);
|
||||
$this->assertNull($result, 'user-create-login-bad-pw-null');
|
||||
$this->assertEquals(Login::ERR_CREDENTIALS, $login->error(), 'user-create-login-bad-pw-error');
|
||||
$this->assertNull(
|
||||
$login->login($this->user->username(), 'not-the-password!', $watch),
|
||||
'user-create-login-bad-pw-null'
|
||||
);
|
||||
$this->assertEquals(
|
||||
Login::ERR_CREDENTIALS,
|
||||
$login->error(),
|
||||
'user-create-login-bad-pw-error'
|
||||
);
|
||||
|
||||
$result = $login->login($this->user->username(), 'password', $watch);
|
||||
$this->assertNull($result, 'user-create-login-unconfirmed-null');
|
||||
$this->assertEquals(Login::ERR_UNCONFIRMED, $login->error(), 'user-create-login-unconfirmed-error');
|
||||
$this->assertNull(
|
||||
$login->login($this->user->username(), 'password', $watch),
|
||||
'user-create-login-unconfirmed-null'
|
||||
);
|
||||
$this->assertEquals(
|
||||
Login::ERR_UNCONFIRMED,
|
||||
$login->error(),
|
||||
'user-create-login-unconfirmed-error',
|
||||
);
|
||||
|
||||
$this->assertEquals(2, $watch->nrAttempts(), 'user-create-two-login-attempts');
|
||||
$this->user->setField('Enabled', '2')->modify();
|
||||
$this->user->setField('Enabled', Enum\UserStatus::enabled->value)->modify();
|
||||
|
||||
$enabledUser = $login->login($this->user->username(), 'password', $watch);
|
||||
$this->assertInstanceOf(User::class, $enabledUser, 'user-create-login-success');
|
||||
$this->assertEquals(0, $watch->nrAttempts(), 'user-create-two-login-cleared');
|
||||
// check the table if this fails
|
||||
$this->assertEquals(0, $watch->nrBans(), 'user-create-two-login-banned');
|
||||
|
||||
$relogin = new Login();
|
||||
$watch->clearAttempts();
|
||||
foreach (range(1, 11) as $nr) {
|
||||
$relogin->login($this->user->username(), "multi-fail-$nr", $watch);
|
||||
}
|
||||
$inbox = $this->user->inbox();
|
||||
$this->assertEquals(1, $inbox->messageTotal(), 'user-login-fail-inbox');
|
||||
$list = $inbox->messageList(new Manager\PM($this->user), 3, 0);
|
||||
$warning = end($list);
|
||||
$this->assertEquals(
|
||||
'Too many login attempts on your account',
|
||||
$warning->subject(),
|
||||
'user-login-pm-subject'
|
||||
);
|
||||
$this->assertEquals(
|
||||
1,
|
||||
$watch->setClear([$watch->id()], $this->user),
|
||||
'user-login-watch-clear',
|
||||
);
|
||||
}
|
||||
|
||||
public function testZeroFailure(): void {
|
||||
|
||||
@@ -23,30 +23,34 @@ class UserMultiFactorAuthTest extends TestCase {
|
||||
|
||||
protected function countTokens(): int {
|
||||
return $this->pg()->scalar('
|
||||
select count(*) from user_token
|
||||
where id_user = ?
|
||||
and type = ?
|
||||
and expiry > now()
|
||||
select count(*)
|
||||
from user_token
|
||||
where expiry > now()
|
||||
and id_user = ?
|
||||
and type = ?
|
||||
', $this->user->id, UserTokenType::mfa->value
|
||||
);
|
||||
}
|
||||
|
||||
public function testMFA(): void {
|
||||
$auth = new \RobThree\Auth\TwoFactorAuth();
|
||||
$secret = $auth->createSecret();
|
||||
$mfa = $this->user->MFA();
|
||||
$auth = new \RobThree\Auth\TwoFactorAuth();
|
||||
$secret = $auth->createSecret();
|
||||
$mfa = $this->user->MFA();
|
||||
$manager = new Manager\UserToken();
|
||||
|
||||
$this->assertEquals(0, $this->countTokens(), 'utest-no-mfa');
|
||||
$recovery = $mfa->create($manager, $secret);
|
||||
$this->assertIsArray($recovery, 'utest-setup-mfa-array');
|
||||
$this->assertCount(10, $recovery, 'utest-setup-mfa-count');
|
||||
$this->assertTrue($this->user->auditTrail()->hasEvent(UserAuditEvent::mfa), 'utest-mfa-audit');
|
||||
$this->assertTrue(
|
||||
$this->user->auditTrail()->hasEvent(UserAuditEvent::mfa),
|
||||
'utest-mfa-audit'
|
||||
);
|
||||
|
||||
$burn = array_pop($recovery);
|
||||
$this->assertFalse($mfa->burnRecovery('no such key'), 'utest-no-burn-mfa');
|
||||
$this->assertTrue($mfa->burnRecovery($burn), 'utest-burn-mfa');
|
||||
Helper::sleepTick(); // pg table's time resolution is too low and causes race
|
||||
Helper::sleepTick(); // wait until the next second
|
||||
$this->assertEquals(9, $this->countTokens(), 'utest-less-mfa');
|
||||
$this->assertFalse($mfa->burnRecovery($burn), 'utest-burn-twice-mfa');
|
||||
|
||||
@@ -66,7 +70,11 @@ class UserMultiFactorAuthTest extends TestCase {
|
||||
);
|
||||
$this->assertCount(4, $mfaList, 'utest-audit-mfa-list');
|
||||
$this->assertStringStartsWith('removed', $mfaList[0]['note'], 'utest-audit-mfa-0');
|
||||
$this->assertEquals("used recovery token $burn", $mfaList[1]['note'], 'utest-audit-mfa-1');
|
||||
$this->assertEquals(
|
||||
"used recovery token $burn from {$this->user->requestContext()->remoteAddr()}",
|
||||
$mfaList[1]['note'],
|
||||
'utest-audit-mfa-1'
|
||||
);
|
||||
$this->assertStringStartsWith('configured', $mfaList[3]['note'], 'utest-audit-mfa-2');
|
||||
$this->assertFalse($mfa->enabled(), 'utest-no-mfa-key');
|
||||
}
|
||||
|
||||
@@ -360,9 +360,9 @@ class UserTest extends TestCase {
|
||||
}
|
||||
|
||||
public function testStylesheet(): void {
|
||||
$manager = new Manager\Stylesheet();
|
||||
global $Cache;
|
||||
$Cache->delete_value('csslist');
|
||||
$Cache->delete_value(Manager\Stylesheet::CACHE_KEY);
|
||||
$manager = new Manager\Stylesheet();
|
||||
$list = $manager->list();
|
||||
$this->assertGreaterThan(5, $list, 'we-can-haz-stylesheets');
|
||||
$this->assertEquals(count($list), count($manager->usageList()), 'stylesheet-list-usage');
|
||||
|
||||
Reference in New Issue
Block a user