refactor 2fa

This commit is contained in:
sheepish
2024-12-04 12:51:40 +00:00
committed by Spine
parent a9165a23b0
commit ce60cc732d
17 changed files with 320 additions and 132 deletions

View File

@@ -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;

View File

@@ -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 = ?

View File

@@ -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);

View File

@@ -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'];
}

View 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
View 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 = ''");

View 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();
}
}

View 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();
}
}

View File

@@ -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,
]);

View File

@@ -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';

View File

@@ -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()}");

View File

@@ -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 %}&nbsp;&nbsp;↳ {% 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 %}
&nbsp;{{ 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 %}

View File

@@ -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&amp;page=user&amp;do=remove&amp;userid={{ user.id }}">Click here to remove</a>
{% else %}
Currently inactive

View File

@@ -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!"> &#x2714;</span>
{%- endif -%}
</li>

View File

@@ -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&amp;do={{ user.TFAKey ? 'remove' : 'configure' }}&amp;userid={{ user.id }}">Click here to {{ user.TFAKey ? 'remove' : 'configure' }}</a>
<a href="user.php?action=2fa&amp;do={{ has_mfa ? 'remove' : 'configure' }}&amp;userid={{ user.id }}">Click here to {{ has_mfa ? 'remove' : 'configure' }}</a>
</td>
</tr>

View 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');
}
}

View File

@@ -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');