mirror of
https://github.com/OPSnet/Gazelle.git
synced 2026-01-16 18:04:34 -05:00
176 lines
5.5 KiB
PHP
176 lines
5.5 KiB
PHP
<?php
|
|
|
|
namespace Gazelle\User;
|
|
|
|
use Gazelle\Enum\UserAuditEvent;
|
|
use Gazelle\Enum\UserTokenType;
|
|
use Gazelle\Manager;
|
|
use Gazelle\User;
|
|
use Endroid\QrCode\Builder\Builder;
|
|
use Endroid\QrCode\Color\Color;
|
|
use Endroid\QrCode\Encoding\Encoding;
|
|
use Endroid\QrCode\ErrorCorrectionLevel;
|
|
use Endroid\QrCode\Label\Label;
|
|
use Endroid\QrCode\Logo\Logo;
|
|
use Endroid\QrCode\QrCode;
|
|
use Endroid\QrCode\RoundBlockSizeMode;
|
|
use Endroid\QrCode\Writer\PngWriter;
|
|
use Endroid\QrCode\Writer\Result\ResultInterface;
|
|
use RobThree\Auth\TwoFactorAuth;
|
|
|
|
class MultiFactorAuth extends \Gazelle\BaseUser {
|
|
protected const RECOVERY_KEY_LEN = 20;
|
|
|
|
private string|false $secret;
|
|
|
|
public function flush(): static {
|
|
unset($this->secret);
|
|
return $this;
|
|
}
|
|
|
|
/**
|
|
* 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->user->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?->id === $this->user->id) {
|
|
$msg .= ' from ' . $this->requestContext()->remoteAddr();
|
|
}
|
|
$this->user->auditTrail()->addEvent(UserAuditEvent::mfa, $msg, $editor ?? $this->user);
|
|
|
|
$this->flush();
|
|
return $recovery;
|
|
}
|
|
|
|
public function generateSessionSecret(): string {
|
|
return new TwoFactorAuth()->createSecret();
|
|
}
|
|
|
|
public function generateQrCode(string $secret, string $logo): ResultInterface {
|
|
$qrCode = new QrCode(
|
|
data: 'otpauth://totp/' . SITE_NAME . "?secret=$secret",
|
|
encoding: new Encoding('UTF-8'),
|
|
errorCorrectionLevel: ErrorCorrectionLevel::Medium,
|
|
size: 400,
|
|
margin: 16,
|
|
roundBlockSizeMode: RoundBlockSizeMode::None,
|
|
backgroundColor: new Color(1, 8, 1),
|
|
foregroundColor: new Color(250, 250, 250)
|
|
);
|
|
$logo = new Logo(
|
|
path: $logo,
|
|
resizeToWidth: 96,
|
|
punchoutBackground: false,
|
|
);
|
|
$label = new Label(
|
|
text: SITE_NAME,
|
|
textColor: new Color(0, 192, 0),
|
|
);
|
|
$writer = new PngWriter();
|
|
return $writer->write($qrCode, $logo, $label);
|
|
}
|
|
|
|
protected function secret(): string|false {
|
|
if (!isset($this->secret)) {
|
|
$this->secret = $this->pg()->scalar('
|
|
select secret from multi_factor_auth where id_user = ?
|
|
', $this->user->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->user->id
|
|
);
|
|
}
|
|
|
|
/**
|
|
* 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 instanceof Token
|
|
&& $userToken->user->id === $this->user->id
|
|
&& $userToken->type() === UserTokenType::mfa
|
|
&& $userToken->consume()
|
|
) {
|
|
if ($this->user->permitted('site_disable_ip_history')) {
|
|
$this->requestContext()->anonymize();
|
|
}
|
|
$this->user->auditTrail()->addEvent(
|
|
UserAuditEvent::mfa,
|
|
"used recovery token $token from {$this->user->requestContext()->remoteAddr()}"
|
|
);
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
public function verifyCode(string $truth, string $code): bool {
|
|
return new TwoFactorAuth()->verifyCode($truth, $code, 2);
|
|
}
|
|
|
|
public function verify(string $token): bool {
|
|
$tfa = new \RobThree\Auth\TwoFactorAuth();
|
|
if (!$tfa->verifyCode($this->secret() ?: '', $token, 2)) {
|
|
// They have MFA 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 remove(): int {
|
|
$this->user->auditTrail()->addEvent(
|
|
UserAuditEvent::mfa,
|
|
"removed via {$this->requestContext()->remoteAddr()}",
|
|
$this->requestContext()->viewer(),
|
|
);
|
|
$affected = $this->pg()->prepared_query("
|
|
delete from multi_factor_auth where id_user = ?
|
|
", $this->user->id
|
|
);
|
|
new Manager\UserToken()->removeTokens($this->user, UserTokenType::mfa);
|
|
$this->flush();
|
|
return $affected;
|
|
}
|
|
}
|