mirror of
https://github.com/OPSnet/Gazelle.git
synced 2026-01-16 18:04:34 -05:00
refactor 2fa
This commit is contained in:
@@ -120,26 +120,16 @@ class Login extends Base {
|
||||
}
|
||||
|
||||
// password checks out, if they have 2FA, does that check out?
|
||||
$TFAKey = $user->TFAKey();
|
||||
if ($TFAKey && !$this->twofa || !$TFAKey && $this->twofa) {
|
||||
$mfa = $user->MFA();
|
||||
if (
|
||||
$mfa->enabled() && !(
|
||||
$this->twofa && $mfa->verify($this->twofa)
|
||||
)
|
||||
|| !$mfa->enabled() && $this->twofa
|
||||
) {
|
||||
$this->error = self::ERR_CREDENTIALS;
|
||||
return null;
|
||||
}
|
||||
if ($TFAKey) {
|
||||
$tfa = new \RobThree\Auth\TwoFactorAuth();
|
||||
if (!$tfa->verifyCode($TFAKey, $this->twofa, 2)) {
|
||||
// They have 2FA but the device key did not match
|
||||
// Fallback to considering it as a recovery key.
|
||||
$userToken = (new Manager\UserToken())->findByToken($this->twofa);
|
||||
if ($userToken) {
|
||||
$userToken->consume();
|
||||
}
|
||||
if (!$user->burn2FARecovery($this->twofa)) {
|
||||
$this->error = self::ERR_CREDENTIALS;
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($user->isUnconfirmed()) {
|
||||
$this->error = self::ERR_UNCONFIRMED;
|
||||
|
||||
@@ -102,6 +102,15 @@ class UserToken extends \Gazelle\BaseManager {
|
||||
);
|
||||
}
|
||||
|
||||
public function removeTokens(\Gazelle\User $user, UserTokenType $type): int {
|
||||
return $this->pg()->prepared_query("
|
||||
delete from user_token
|
||||
where id_user = ?
|
||||
and type = ?
|
||||
", $user->id(), $type->value
|
||||
);
|
||||
}
|
||||
|
||||
public function removeUser(\Gazelle\User $user): int {
|
||||
return $this->pg()->prepared_query("
|
||||
delete from user_token where id_user = ?
|
||||
|
||||
@@ -5,6 +5,8 @@ namespace Gazelle\Stats;
|
||||
use Gazelle\Enum\UserStatus;
|
||||
|
||||
class Economic extends \Gazelle\Base {
|
||||
use \Gazelle\Pg;
|
||||
|
||||
final public const CACHE_KEY = 'stats_eco';
|
||||
|
||||
protected array $info;
|
||||
@@ -88,10 +90,8 @@ class Economic extends \Gazelle\Base {
|
||||
AND active = 1
|
||||
");
|
||||
|
||||
$info['user_mfa_total'] = (int)self::$db->scalar("
|
||||
SELECT count(*)
|
||||
FROM users_main
|
||||
WHERE 2FA_Key IS NOT NULL AND 2FA_Key != ''
|
||||
$info['user_mfa_total'] = (int)$this->pg()->scalar("
|
||||
select count(*) from multi_factor_auth
|
||||
");
|
||||
$info = array_map('intval', $info); // some db results are stringified
|
||||
self::$cache->cache_value(self::CACHE_KEY, $info, 3600);
|
||||
|
||||
77
app/User.php
77
app/User.php
@@ -3,9 +3,8 @@
|
||||
namespace Gazelle;
|
||||
|
||||
use Gazelle\Enum\AvatarDisplay;
|
||||
use Gazelle\Enum\UserAuditEvent;
|
||||
use Gazelle\Enum\UserStatus;
|
||||
use Gazelle\Enum\UserTokenType;
|
||||
use Gazelle\User\MultiFactorAuth;
|
||||
use Gazelle\Util\Irc;
|
||||
use Gazelle\Util\Mail;
|
||||
use Gazelle\Util\Time;
|
||||
@@ -154,7 +153,6 @@ class User extends BaseObject {
|
||||
um.Title,
|
||||
um.torrent_pass,
|
||||
um.Visible,
|
||||
um.2FA_Key,
|
||||
ui.AdminComment,
|
||||
ui.BanDate,
|
||||
ui.NavItems,
|
||||
@@ -562,10 +560,6 @@ class User extends BaseObject {
|
||||
return $this->info()['AdminComment'];
|
||||
}
|
||||
|
||||
public function TFAKey(): ?string {
|
||||
return $this->info()['2FA_Key'];
|
||||
}
|
||||
|
||||
public function title(): ?string {
|
||||
return $this->info()['Title'];
|
||||
}
|
||||
@@ -591,6 +585,10 @@ class User extends BaseObject {
|
||||
return $this->info()['Username'];
|
||||
}
|
||||
|
||||
public function MFA(): MultiFactorAuth {
|
||||
return new MultiFactorAuth($this);
|
||||
}
|
||||
|
||||
public function userStatus(): UserStatus {
|
||||
return match ($this->info()['Enabled']) {
|
||||
'1' => UserStatus::enabled,
|
||||
@@ -599,71 +597,6 @@ class User extends BaseObject {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the recovery keys for the user
|
||||
*/
|
||||
public function create2FA(Manager\UserToken $manager, string $key): int {
|
||||
$unique = [];
|
||||
while (count($unique) < 10) {
|
||||
$unique[randomString(20)] = 1;
|
||||
}
|
||||
$recovery = array_keys($unique);
|
||||
self::$db->prepared_query("
|
||||
UPDATE users_main SET
|
||||
2FA_Key = ?,
|
||||
Recovery = ?
|
||||
WHERE ID = ?
|
||||
", $key, serialize($recovery), $this->id
|
||||
);
|
||||
$affected = self::$db->affected_rows();
|
||||
foreach ($recovery as $value) {
|
||||
$manager->create(UserTokenType::mfa, user: $this, value: $value);
|
||||
}
|
||||
$this->auditTrail()->addEvent(UserAuditEvent::mfa, 'configured');
|
||||
$this->flush();
|
||||
return $affected;
|
||||
}
|
||||
|
||||
public function list2FA(): array {
|
||||
return unserialize((string)self::$db->scalar("
|
||||
SELECT Recovery FROM users_main WHERE ID = ?
|
||||
", $this->id
|
||||
)) ?: [];
|
||||
}
|
||||
|
||||
/**
|
||||
* A user is attempting to login with 2FA via a recovery key
|
||||
* If we have the key on record, burn it and let them in.
|
||||
*
|
||||
* @param string $key Recovery key from user
|
||||
* @return bool Valid key, they may log in.
|
||||
*/
|
||||
public function burn2FARecovery(string $key): bool {
|
||||
$list = $this->list2FA();
|
||||
$index = array_search($key, $list);
|
||||
if ($index === false) {
|
||||
return false;
|
||||
}
|
||||
unset($list[$index]);
|
||||
self::$db->prepared_query('
|
||||
UPDATE users_main SET
|
||||
Recovery = ?
|
||||
WHERE ID = ?
|
||||
', count($list) === 0 ? null : serialize($list), $this->id
|
||||
);
|
||||
$burnt = self::$db->affected_rows() === 1;
|
||||
if ($burnt) {
|
||||
$this->auditTrail()->addEvent(UserAuditEvent::mfa, "used token $key");
|
||||
}
|
||||
return $burnt;
|
||||
}
|
||||
|
||||
public function remove2FA(): static {
|
||||
$this->auditTrail()->addEvent(UserAuditEvent::mfa, "removed");
|
||||
return $this->setField('2FA_Key', null)
|
||||
->setField('Recovery', null);
|
||||
}
|
||||
|
||||
public function paranoia(): array {
|
||||
return $this->info()['Paranoia'];
|
||||
}
|
||||
|
||||
123
app/User/MultiFactorAuth.php
Normal file
123
app/User/MultiFactorAuth.php
Normal file
@@ -0,0 +1,123 @@
|
||||
<?php
|
||||
|
||||
namespace Gazelle\User;
|
||||
|
||||
use Gazelle\Enum\UserAuditEvent;
|
||||
use Gazelle\Enum\UserTokenType;
|
||||
use Gazelle\Manager;
|
||||
use Gazelle\User;
|
||||
|
||||
class MultiFactorAuth extends \Gazelle\BaseUser {
|
||||
use \Gazelle\Pg;
|
||||
|
||||
protected const RECOVERY_KEY_LEN = 20;
|
||||
|
||||
private string|false $secret;
|
||||
|
||||
protected function secret(): string|false {
|
||||
if (!isset($this->secret)) {
|
||||
$this->secret = $this->pg()->scalar('
|
||||
select secret from multi_factor_auth where id_user = ?
|
||||
', $this->id()
|
||||
) ?? false;
|
||||
}
|
||||
return $this->secret;
|
||||
}
|
||||
|
||||
public function enabled(): bool {
|
||||
return $this->secret() !== false;
|
||||
}
|
||||
|
||||
public function details(): ?array {
|
||||
return $this->pg()->rowAssoc('
|
||||
select ip, created from multi_factor_auth where id_user = ?
|
||||
', $this->id()
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the recovery keys for the user
|
||||
*/
|
||||
public function create(Manager\UserToken $manager, #[\SensitiveParameter] string $key, ?User $editor = null): ?array {
|
||||
$affectedRows = $this->pg()->prepared_query("
|
||||
insert into multi_factor_auth
|
||||
(id_user, secret, ip)
|
||||
values (?, ?, ?)
|
||||
", $this->id(), $key, $this->requestContext()->remoteAddr()
|
||||
);
|
||||
if ($affectedRows < 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$unique = [];
|
||||
while (count($unique) < 10) {
|
||||
$unique[randomString(self::RECOVERY_KEY_LEN)] = 1;
|
||||
}
|
||||
$recovery = array_keys($unique);
|
||||
foreach ($recovery as $value) {
|
||||
$manager->create(UserTokenType::mfa, user: $this->user, value: $value);
|
||||
}
|
||||
|
||||
$msg = 'configured';
|
||||
if (!$editor || $editor->id() === $this->id()) {
|
||||
$msg .= ' from ' . $this->requestContext()->remoteAddr();
|
||||
}
|
||||
$this->user->auditTrail()->addEvent(UserAuditEvent::mfa, $msg, $editor ?? $this->user);
|
||||
|
||||
$this->flush();
|
||||
return $recovery;
|
||||
}
|
||||
|
||||
/**
|
||||
* A user is attempting to log in with MFA via a recovery key
|
||||
* If we have the key on record, burn it and let them in.
|
||||
*
|
||||
* @param string $token Recovery token given by user
|
||||
* @return bool Valid key, they may log in.
|
||||
*/
|
||||
public function burnRecovery(string $token): bool {
|
||||
$userToken = (new Manager\UserToken())->findByToken($token);
|
||||
if (
|
||||
$userToken
|
||||
&& $userToken->user->id() === $this->id()
|
||||
&& $userToken->type() === UserTokenType::mfa
|
||||
&& $userToken->consume()
|
||||
) {
|
||||
$this->user->auditTrail()->addEvent(UserAuditEvent::mfa, "used recovery token $token");
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
public function remove(?User $editor = null): static {
|
||||
$msg = 'removed';
|
||||
if (!$editor || $editor->id() === $this->id()) {
|
||||
$msg .= ' from ' . $this->requestContext()->remoteAddr();
|
||||
}
|
||||
$this->user->auditTrail()->addEvent(UserAuditEvent::mfa, $msg, $editor ?? $this->user);
|
||||
$this->pg()->prepared_query('
|
||||
delete from multi_factor_auth where id_user = ?
|
||||
', $this->id()
|
||||
);
|
||||
(new Manager\UserToken())->removeTokens($this->user, UserTokenType::mfa);
|
||||
return $this->flush();
|
||||
}
|
||||
|
||||
public function verify(string $token): bool {
|
||||
$tfa = new \RobThree\Auth\TwoFactorAuth();
|
||||
if (!$tfa->verifyCode($this->secret() ?: '', $token, 2)) {
|
||||
// They have 2FA but the device key did not match
|
||||
// Fallback to considering it as a recovery key.
|
||||
if (strlen($token) === self::RECOVERY_KEY_LEN) {
|
||||
return $this->burnRecovery($token);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
public function flush(): static {
|
||||
unset($this->secret);
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
33
bin/migrate-mfa
Executable file
33
bin/migrate-mfa
Executable file
@@ -0,0 +1,33 @@
|
||||
#! /usr/bin/env php
|
||||
<?php
|
||||
|
||||
require_once(__DIR__ . "/../lib/bootstrap.php");
|
||||
|
||||
function genPlaceholders(int $num, string $val) {
|
||||
for ($i = 1; $i <= $num; $i++) {
|
||||
yield $val;
|
||||
}
|
||||
}
|
||||
|
||||
$db = Gazelle\DB::DB();
|
||||
$pg = new \Gazelle\DB\Pg(GZPG_DSN);
|
||||
|
||||
$values = [];
|
||||
|
||||
$db->prepared_query("
|
||||
SELECT ID, 2FA_Key FROM users_main WHERE 2FA_Key IS NOT NULL AND 2FA_Key != '' ORDER BY ID
|
||||
");
|
||||
while ($row = $db->next_row(MYSQLI_ASSOC)) {
|
||||
array_push($values, $row['ID'], $row['2FA_Key']);
|
||||
}
|
||||
|
||||
$pg->prepared_query('
|
||||
insert into multi_factor_auth
|
||||
(id_user, secret, ip, created)
|
||||
values ' . implode(', ', iterator_to_array(genPlaceholders(
|
||||
count($values) / 2,
|
||||
"(?, ?, '0.0.0.0', '1970-01-01 00:00')"))),
|
||||
...$values
|
||||
);
|
||||
|
||||
$db->prepared_query("UPDATE users_main SET 2FA_Key = ''");
|
||||
16
misc/phinx-pg/migrations/20241204000000_multi_factor_auth.php
Executable file
16
misc/phinx-pg/migrations/20241204000000_multi_factor_auth.php
Executable file
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Phinx\Migration\AbstractMigration;
|
||||
|
||||
final class MultiFactorAuth extends AbstractMigration {
|
||||
public function change(): void {
|
||||
$this->table('multi_factor_auth', ['id' => false, 'primary_key' => 'id_user'])
|
||||
->addColumn('id_user', 'integer', ['identity' => true])
|
||||
->addColumn('secret', 'text')
|
||||
->addColumn('ip', 'inet')
|
||||
->addColumn('created', 'timestamp', ['default' => 'CURRENT_TIMESTAMP'])
|
||||
->create();
|
||||
}
|
||||
}
|
||||
31
misc/phinx/migrations/20241204000000_remove_mfa.php
Normal file
31
misc/phinx/migrations/20241204000000_remove_mfa.php
Normal file
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
use Phinx\Migration\AbstractMigration;
|
||||
|
||||
final class RemoveMfa extends AbstractMigration {
|
||||
public function up(): void {
|
||||
if ($this->fetchRow('select count(*) from users_main where 2FA_Key != ""')[0] > 0) { // @phpstan-ignore-line
|
||||
throw new RuntimeException('MFA keys not migrated yet. execute bin/migrate-mfa first.');
|
||||
}
|
||||
$this->table('users_main')
|
||||
->removeColumn('2FA_Key')
|
||||
->removeColumn('Recovery')
|
||||
->save();
|
||||
}
|
||||
|
||||
public function down(): void {
|
||||
$this->table('users_main')
|
||||
->addColumn('2FA_Key', 'string', [
|
||||
'null' => true,
|
||||
'default' => null,
|
||||
'limit' => 16,
|
||||
])
|
||||
->addColumn('Recovery', 'text', [
|
||||
'null' => true,
|
||||
'default' => null,
|
||||
'limit' => 65535,
|
||||
])
|
||||
->save();
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,7 @@ $user = (new Gazelle\Manager\User())->findById((int)($_REQUEST['userid'] ?? 0));
|
||||
if (is_null($user)) {
|
||||
error(404);
|
||||
}
|
||||
if ($user->TFAKey()) {
|
||||
if ($user->MFA()->enabled()) {
|
||||
error($Viewer->permitted('users_edit_password') ? '2FA is already configured' : 404);
|
||||
}
|
||||
|
||||
@@ -17,7 +17,10 @@ if (empty($_SESSION['private_key'])) {
|
||||
error(404);
|
||||
}
|
||||
|
||||
$user->create2FA(new Gazelle\Manager\UserToken(), $_SESSION['private_key']);
|
||||
$recoveryKeys = $user->MFA()->create(new Gazelle\Manager\UserToken(), $_SESSION['private_key'], $Viewer);
|
||||
if (!$recoveryKeys) {
|
||||
error('failed to create 2FA');
|
||||
}
|
||||
|
||||
if (session_status() === PHP_SESSION_NONE) {
|
||||
session_start();
|
||||
@@ -26,5 +29,5 @@ unset($_SESSION['private_key']);
|
||||
session_write_close();
|
||||
|
||||
echo $Twig->render('user/2fa/complete.twig', [
|
||||
'keys' => $user->list2FA(),
|
||||
'keys' => $recoveryKeys,
|
||||
]);
|
||||
|
||||
@@ -11,7 +11,7 @@ if ($user->id() != $Viewer->id() && !$Viewer->permitted('users_mod')) {
|
||||
|
||||
switch ($_GET['do'] ?? '') {
|
||||
case 'configure':
|
||||
if ($user->TFAKey()) {
|
||||
if ($user->MFA()->enabled()) {
|
||||
error($Viewer->permitted('users_edit_password') ? '2FA is already configured' : 404);
|
||||
}
|
||||
include_once 'configure.php';
|
||||
|
||||
@@ -6,7 +6,7 @@ $user = (new Gazelle\Manager\User())->findById((int)($_GET['userid'] ?? 0));
|
||||
if (is_null($user)) {
|
||||
error(404);
|
||||
}
|
||||
if (!$user->TFAKey()) {
|
||||
if (!$user->MFA()->enabled()) {
|
||||
error($Viewer->permitted('users_edit_password') ? 'No 2FA configured' : 404);
|
||||
}
|
||||
|
||||
@@ -22,6 +22,6 @@ if (!$Viewer->permitted('users_edit_password')) {
|
||||
exit;
|
||||
}
|
||||
}
|
||||
$user->remove2FA()->modify();
|
||||
$user->MFA()->remove($Viewer);
|
||||
|
||||
header("Location: {$user->location()}");
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
{% from 'macro/form.twig' import selected -%}
|
||||
{% from 'macro/ipv4.twig' import ip_search %}
|
||||
{% set mfa = user.MFA.details %}
|
||||
{{ header(user.username ~ ' › Email and IP summary', {'js': 'resolve-ip'}) }}
|
||||
<div class="box pad center">
|
||||
<h2>{{ user.id|user_url }} › Email and IP summary</h2>
|
||||
@@ -16,7 +18,7 @@
|
||||
{% for parent in ancestry %}
|
||||
{% if not loop.first %} ↳ {% endif %}
|
||||
{{ parent.id|user_url }}
|
||||
{% if parent.isDisabled %}<span title="disabled"> ⛔️<span>{% endif %}
|
||||
{% if parent.isDisabled %}<span title="disabled"> ⛔️</span>{% endif %}
|
||||
{% if parent.disableInvites %}<span title="invites revoked"> 🚫</span>{% endif %}
|
||||
{{ parent.publicLocation -}}
|
||||
{% set source = invite_source.findSourceNameByUser(current) %}
|
||||
@@ -29,6 +31,10 @@
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
</td></tr>
|
||||
<tr><th>2FA</th><td colspan="6">{% if mfa -%}
|
||||
enabled since {{ mfa.created }} from {{ ip_search(mfa.ip) }}
|
||||
{%- else %}disabled{% endif -%}
|
||||
</td></tr>
|
||||
|
||||
{% include 'admin/user-info-email.twig' with {'info': hist.email(asn), 'title': 'Email History' } only %}
|
||||
{% include 'admin/user-info-ipv4.twig' with {'info': hist.siteIPv4(asn), 'title': 'Site IPv4 History' } only %}
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
<tr>
|
||||
<td class="label">Two-factor Authentication:</td>
|
||||
<td>
|
||||
{% if user.TFAKey %}
|
||||
{% if user.MFA.enabled %}
|
||||
<a href="user.php?action=2fa&page=user&do=remove&userid={{ user.id }}">Click here to remove</a>
|
||||
{% else %}
|
||||
Currently inactive
|
||||
|
||||
@@ -125,7 +125,7 @@ Torrent clients: {{ user.clients|join('; ') }}</div>
|
||||
<div class="head colhead_dark">Statistics</div>
|
||||
<ul class="stats nobullet">
|
||||
<li>Joined: {{ user.created|time_diff }}
|
||||
{%- if (own_profile or viewer.permitted('users_mod')) and user.TFAKey -%}
|
||||
{%- if (own_profile or viewer.permitted('users_mod')) and user.MFA.enabled -%}
|
||||
<span class="tooltip" style="color: #008000" title="2FA enabled!"> ✔</span>
|
||||
{%- endif -%}
|
||||
</li>
|
||||
|
||||
@@ -82,9 +82,10 @@
|
||||
<tr id="acc_2fa_tr">
|
||||
<td class="label"><strong>Two-factor Authentication</strong></td>
|
||||
<td>
|
||||
Two-factor authentication is currently <strong class="{{ user.TFAKey ? 'r99' : 'warning' }}">{{ user.TFAKey ? 'enabled' : 'disabled' }}</strong> for your account.
|
||||
{% set has_mfa = user.MFA.enabled %}
|
||||
Two-factor authentication is currently <strong class="{{ has_mfa ? 'r99' : 'warning' }}">{{ has_mfa ? 'enabled' : 'disabled' }}</strong> for your account.
|
||||
<br><br>
|
||||
<a href="user.php?action=2fa&do={{ user.TFAKey ? 'remove' : 'configure' }}&userid={{ user.id }}">Click here to {{ user.TFAKey ? 'remove' : 'configure' }}</a>
|
||||
<a href="user.php?action=2fa&do={{ has_mfa ? 'remove' : 'configure' }}&userid={{ user.id }}">Click here to {{ has_mfa ? 'remove' : 'configure' }}</a>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
|
||||
71
tests/phpunit/UserMultiFactorAuthTest.php
Normal file
71
tests/phpunit/UserMultiFactorAuthTest.php
Normal file
@@ -0,0 +1,71 @@
|
||||
<?php
|
||||
|
||||
namespace Gazelle;
|
||||
|
||||
use Gazelle\Enum\UserTokenType;
|
||||
use Gazelle\Enum\UserAuditEvent;
|
||||
use GazelleUnitTest\Helper;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
class UserMultiFactorAuthTest extends TestCase {
|
||||
use Pg;
|
||||
|
||||
protected User $user;
|
||||
|
||||
public function setUp(): void {
|
||||
$this->user = Helper::makeUser('mfa.' . randomString(10), 'mfa');
|
||||
}
|
||||
|
||||
public function tearDown(): void {
|
||||
(new Manager\UserToken())->removeUser($this->user);
|
||||
$this->user->remove();
|
||||
}
|
||||
|
||||
protected function countTokens(): int {
|
||||
return $this->pg()->scalar('
|
||||
select count(*) from user_token
|
||||
where id_user = ?
|
||||
and type = ?
|
||||
and expiry > now()
|
||||
', $this->user->id(), UserTokenType::mfa->value
|
||||
);
|
||||
}
|
||||
|
||||
public function testMFA(): void {
|
||||
$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->assertCount(10, $recovery, 'utest-setup-mfa');
|
||||
$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');
|
||||
sleep(1); // pg table's time resolution is too low and causes race
|
||||
$this->assertEquals(9, $this->countTokens(), 'utest-less-mfa');
|
||||
$this->assertFalse($mfa->burnRecovery($burn), 'utest-burn-twice-mfa');
|
||||
|
||||
$burn = array_pop($recovery);
|
||||
$this->assertTrue($mfa->verify($burn), 'utest-burn-verify-mfa');
|
||||
$this->assertFalse($mfa->verify('invalid'), 'utest-verify-bad-mfa');
|
||||
$this->assertTrue($mfa->verify($auth->getCode($secret)), 'utest-verify-good-mfa');
|
||||
$this->assertTrue($mfa->enabled(), 'utest-has-mfa-key');
|
||||
|
||||
$mfa->remove();
|
||||
$this->assertEquals(0, $this->countTokens(), 'utest-remove-mfa');
|
||||
$mfaList = array_filter(
|
||||
$this->user->auditTrail()->eventList(),
|
||||
fn ($e) => $e['event'] === UserAuditEvent::mfa->value
|
||||
|
||||
);
|
||||
$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->assertStringStartsWith('configured', $mfaList[3]['note'], 'utest-audit-mfa-2');
|
||||
$this->assertFalse($mfa->enabled(), 'utest-no-mfa-key');
|
||||
}
|
||||
}
|
||||
@@ -72,34 +72,6 @@ class UserTokenTest extends TestCase {
|
||||
$this->assertNull($manager->findByUser($this->user, UserTokenType::mfa), 'usertoken-missing');
|
||||
}
|
||||
|
||||
public function testMFA(): void {
|
||||
$manager = new Manager\UserToken();
|
||||
$this->assertCount(0, $this->user->list2FA(), 'utest-no-mfa');
|
||||
$this->assertEquals(1, $this->user->create2FA($manager, randomString(16)), 'utest-setup-mfa');
|
||||
$this->assertTrue($this->user->auditTrail()->hasEvent(UserAuditEvent::mfa), 'utest-mfa-audit');
|
||||
$recovery = $this->user->list2FA();
|
||||
$this->assertCount(10, $recovery, 'utest-list-mfa');
|
||||
|
||||
$burn = array_pop($recovery);
|
||||
$this->assertFalse($this->user->burn2FARecovery('no such key'), 'utest-no-burn-mfa');
|
||||
$this->assertTrue($this->user->burn2FARecovery($burn), 'utest-burn-mfa');
|
||||
$this->assertCount(9, $this->user->list2FA(), 'utest-less-mfa');
|
||||
$this->assertNotNull($this->user->TFAKey(), 'utest-has-mfa-key');
|
||||
|
||||
$this->user->remove2FA()->modify();
|
||||
$this->assertCount(0, $this->user->list2FA(), 'utest-remove-mfa');
|
||||
$mfaList = array_filter(
|
||||
$this->user->auditTrail()->eventList(),
|
||||
fn ($e) => $e['event'] === UserAuditEvent::mfa->value
|
||||
|
||||
);
|
||||
$this->assertCount(3, $mfaList, 'utest-audit-mfa-list');
|
||||
$this->assertEquals('removed', $mfaList[0]['note'], 'utest-audit-mfa-0');
|
||||
$this->assertEquals("used token $burn", $mfaList[1]['note'], 'utest-audit-mfa-1');
|
||||
$this->assertEquals('configured', $mfaList[2]['note'], 'utest-audit-mfa-2');
|
||||
$this->assertNull($this->user->TFAKey(), 'utest-no-mfa-key');
|
||||
}
|
||||
|
||||
public function testApiToken(): void {
|
||||
$this->assertCount(0, $this->user->apiTokenList(), 'user-token-none-creted');
|
||||
$this->assertFalse($this->user->hasApiToken('no such token'), 'user-token-missing');
|
||||
|
||||
Reference in New Issue
Block a user