diff --git a/database/2025_02_08_000526_create_users_totp_table.php b/database/2025_02_08_000526_create_users_totp_table.php new file mode 100644 index 00000000..a2ae1fa1 --- /dev/null +++ b/database/2025_02_08_000526_create_users_totp_table.php @@ -0,0 +1,33 @@ +<?php +use Index\Base32; +use Index\Db\DbConnection; +use Index\Db\Migration\DbMigration; + +final class CreateUsersTotpTable_20250208_000526 implements DbMigration { + public function migrate(DbConnection $conn): void { + $conn->execute(<<<SQL + CREATE TABLE msz_users_totp ( + user_id INT UNSIGNED NOT NULL, + totp_secret BINARY(16) NOT NULL, + totp_created TIMESTAMP NOT NULL DEFAULT NOW(), + PRIMARY KEY (user_id), + INDEX users_totp_created_index (totp_created), + CONSTRAINT users_totp_users_foreign + FOREIGN KEY (user_id) + REFERENCES msz_users (user_id) + ON UPDATE CASCADE + ON DELETE CASCADE + ) COLLATE='utf8mb4_bin'; + SQL); + + $result = $conn->query('SELECT user_id, user_totp_key FROM msz_users WHERE user_totp_key IS NOT NULL'); + $stmt = $conn->prepare('INSERT INTO msz_users_totp (user_id, totp_secret) VALUES (?, ?)'); + while($result->next()) { + $stmt->addParameter(1, $result->getString(0)); + $stmt->addParameter(2, Base32::decode($result->getString(1))); + $stmt->execute(); + } + + $conn->execute('ALTER TABLE msz_users DROP COLUMN user_totp_key'); + } +} diff --git a/public-legacy/auth/login.php b/public-legacy/auth/login.php index 84bbdb1a..613b914d 100644 --- a/public-legacy/auth/login.php +++ b/public-legacy/auth/login.php @@ -118,7 +118,7 @@ while(!empty($_POST['login']) && is_array($_POST['login'])) { break; } - if($userInfo->hasTOTP) { + if($msz->usersCtx->totps->hasUserTotp($userInfo)) { $tfaToken = $msz->authCtx->tfaSessions->createToken($userInfo); Tools::redirect($msz->urls->format('auth-two-factor', ['token' => $tfaToken, 'redirect' => $loginRedirect])); return; diff --git a/public-legacy/auth/password.php b/public-legacy/auth/password.php index 23b9bae7..2d4c55f1 100644 --- a/public-legacy/auth/password.php +++ b/public-legacy/auth/password.php @@ -71,7 +71,8 @@ while($canResetPassword) { // also disables two factor auth to prevent getting locked out of account entirely // this behaviour should really be replaced with recovery keys... - $msz->usersCtx->users->updateUser($userInfo, password: $passwordNew, totpKey: ''); + $msz->usersCtx->users->updateUser($userInfo, password: $passwordNew); + $msz->usersCtx->totps->deleteUserTotp($userInfo); $msz->createAuditLog('PASSWORD_RESET', [], $userInfo); diff --git a/public-legacy/auth/twofactor.php b/public-legacy/auth/twofactor.php index b571b399..f8a272e1 100644 --- a/public-legacy/auth/twofactor.php +++ b/public-legacy/auth/twofactor.php @@ -2,7 +2,7 @@ namespace Misuzu; use RuntimeException; -use Misuzu\TOTPGenerator; +use Misuzu\TotpGenerator; use Misuzu\Auth\AuthTokenCookie; if(!isset($msz) || !($msz instanceof \Misuzu\MisuzuContext)) @@ -31,11 +31,8 @@ if(empty($tokenUserId)) { return; } -$userInfo = $msz->usersCtx->users->getUser($tokenUserId, 'id'); - -// checking user_totp_key specifically because there's a fringe chance that -// there's a token present, but totp is actually disabled -if(!$userInfo->hasTOTP) { +$totpInfo = $msz->usersCtx->totps->getUserTotp($tokenUserId); +if($totpInfo === null) { Tools::redirect($msz->urls->format('auth-login')); return; } @@ -60,30 +57,30 @@ while(!empty($twofactor)) { } $clientInfo = ClientInfo::fromRequest(); - $totp = new TOTPGenerator($userInfo->totpKey); + $generator = $totpInfo->createGenerator(); - if(!in_array($twofactor['code'], $totp->generateRange())) { + if(!in_array($twofactor['code'], $generator->generateRange())) { $notices[] = sprintf( "Invalid two factor code, %d attempt%s remaining", $remainingAttempts - 1, $remainingAttempts === 2 ? '' : 's' ); - $msz->authCtx->loginAttempts->recordAttempt(false, $ipAddress, $countryCode, $userAgent, $clientInfo, $userInfo); + $msz->authCtx->loginAttempts->recordAttempt(false, $ipAddress, $countryCode, $userAgent, $clientInfo, $tokenUserId); break; } - $msz->authCtx->loginAttempts->recordAttempt(true, $ipAddress, $countryCode, $userAgent, $clientInfo, $userInfo); + $msz->authCtx->loginAttempts->recordAttempt(true, $ipAddress, $countryCode, $userAgent, $clientInfo, $tokenUserId); $msz->authCtx->tfaSessions->deleteToken($tokenString); try { - $sessionInfo = $msz->authCtx->sessions->createSession($userInfo, $ipAddress, $countryCode, $userAgent, $clientInfo); + $sessionInfo = $msz->authCtx->sessions->createSession($tokenUserId, $ipAddress, $countryCode, $userAgent, $clientInfo); } catch(RuntimeException $ex) { $notices[] = "Something broke while creating a session for you, please tell an administrator or developer about this!"; break; } $tokenBuilder = $msz->authInfo->tokenInfo->toBuilder(); - $tokenBuilder->setUserId($userInfo); + $tokenBuilder->setUserId($tokenUserId); $tokenBuilder->setSessionToken($sessionInfo); $tokenBuilder->removeImpersonatedUserId(); $tokenInfo = $tokenBuilder->toInfo(); diff --git a/public-legacy/settings/account.php b/public-legacy/settings/account.php index 6bde1e12..77fab736 100644 --- a/public-legacy/settings/account.php +++ b/public-legacy/settings/account.php @@ -15,6 +15,7 @@ if(!$msz->authInfo->loggedIn) $errors = []; $userInfo = $msz->authInfo->userInfo; $isRestricted = $msz->usersCtx->hasActiveBan($userInfo); +$hasTotp = $msz->usersCtx->totps->hasUserTotp($userInfo); $isVerifiedRequest = CSRF::validateRequest(); if(!$isRestricted && $isVerifiedRequest && !empty($_POST['role'])) { @@ -47,28 +48,29 @@ if(!$isRestricted && $isVerifiedRequest && !empty($_POST['role'])) { } } -if($isVerifiedRequest && isset($_POST['tfa']['enable']) && $userInfo->hasTOTP !== (bool)$_POST['tfa']['enable']) { - $totpKey = ''; - +if($isVerifiedRequest && isset($_POST['tfa']['enable']) && $msz->usersCtx->totps->hasUserTotp($userInfo) !== (bool)$_POST['tfa']['enable']) { if((bool)$_POST['tfa']['enable']) { - $totpKey = TOTPGenerator::generateKey(); + $totpInfo = $msz->usersCtx->totps->createUserTotp($userInfo); + $totpSecret = $totpInfo->encodedSecret; $totpIssuer = $msz->siteInfo->name; $totpQrcode = (new QRCode(new QROptions([ 'version' => 5, 'outputType' => QRCode::OUTPUT_IMAGE_JPG, 'eccLevel' => QRCode::ECC_L, ])))->render(sprintf('otpauth://totp/%s:%s?%s', $totpIssuer, $userInfo->name, http_build_query([ - 'secret' => $totpKey, + 'secret' => $totpSecret, 'issuer' => $totpIssuer, ]))); + $hasTotp = true; Template::set([ - 'settings_2fa_code' => $totpKey, + 'settings_2fa_code' => $totpSecret, 'settings_2fa_image' => $totpQrcode, ]); + } else { + $hasTotp = false; + $msz->usersCtx->totps->deleteUserTotp($userInfo); } - - $msz->usersCtx->users->updateUser(userInfo: $userInfo, totpKey: $totpKey); } if($isVerifiedRequest && !empty($_POST['current_password'])) { @@ -122,4 +124,5 @@ Template::render('settings.account', [ 'settings_user' => $userInfo, 'settings_roles' => $userRoles, 'is_restricted' => $isRestricted, + 'has_totp' => $hasTotp, ]); diff --git a/public-legacy/settings/data.php b/public-legacy/settings/data.php index 1f269067..9103d162 100644 --- a/public-legacy/settings/data.php +++ b/public-legacy/settings/data.php @@ -143,7 +143,7 @@ if(isset($_POST['action']) && is_string($_POST['action'])) { $tmpFiles[] = db_to_zip($archive, $userInfo, 'perms_calculated', ['user_id:s:n', 'forum_id:s:n', 'perms_category:s', 'perms_calculated:i']); $tmpFiles[] = db_to_zip($archive, $userInfo, 'profile_fields_values', ['field_id:s', 'user_id:s', 'format_id:s', 'field_value:s']); $tmpFiles[] = db_to_zip($archive, $userInfo, 'sessions', ['session_id:s', 'user_id:s', 'session_key:n', 'session_remote_addr_first:a', 'session_remote_addr_last:a:n', 'session_user_agent:s', 'session_country:s', 'session_expires:t', 'session_expires_bump:b', 'session_created:t', 'session_active:t:n']); - $tmpFiles[] = db_to_zip($archive, $userInfo, 'users', ['user_id:s', 'user_name:s', 'user_password:n', 'user_email:s', 'user_remote_addr_first:a', 'user_remote_addr_last:a', 'user_super:b', 'user_country:s', 'user_colour:i:n', 'user_created:t', 'user_active:t:n', 'user_deleted:t:n', 'user_display_role_id:s:n', 'user_totp_key:n', 'user_about_content:s:n', 'user_about_parser:i', 'user_signature_content:s:n', 'user_signature_parser:i', 'user_birthdate:s:n', 'user_background_settings:i:n', 'user_title:s:n']); + $tmpFiles[] = db_to_zip($archive, $userInfo, 'users', ['user_id:s', 'user_name:s', 'user_password:n', 'user_email:s', 'user_remote_addr_first:a', 'user_remote_addr_last:a', 'user_super:b', 'user_country:s', 'user_colour:i:n', 'user_created:t', 'user_active:t:n', 'user_deleted:t:n', 'user_display_role_id:s:n', 'user_about_content:s:n', 'user_about_parser:i', 'user_signature_content:s:n', 'user_signature_parser:i', 'user_birthdate:s:n', 'user_background_settings:i:n', 'user_title:s:n']); $tmpFiles[] = db_to_zip($archive, $userInfo, 'users_bans', ['ban_id:s', 'user_id:s', 'mod_id:n', 'ban_severity:i', 'ban_reason_public:s', 'ban_reason_private:s', 'ban_created:t', 'ban_expires:t:n']); $tmpFiles[] = db_to_zip($archive, $userInfo, 'users_password_resets', ['reset_id:s', 'user_id:s', 'reset_remote_addr:a', 'reset_requested:t', 'reset_code:n']); $tmpFiles[] = db_to_zip($archive, $userInfo, 'users_warnings', ['warn_id:s', 'user_id:s', 'mod_id:n', 'warn_body:s', 'warn_created:t']); diff --git a/src/TOTPGenerator.php b/src/TotpGenerator.php similarity index 81% rename from src/TOTPGenerator.php rename to src/TotpGenerator.php index a5459fbf..0e2e5929 100644 --- a/src/TOTPGenerator.php +++ b/src/TotpGenerator.php @@ -2,17 +2,14 @@ namespace Misuzu; use InvalidArgumentException; -use Index\Base32; -class TOTPGenerator { +class TotpGenerator { public const DIGITS = 6; public const INTERVAL = 30000; - public function __construct(private string $secretKey) {} - - public static function generateKey(): string { - return Base32::encode(random_bytes(16)); - } + public function __construct( + #[\SensitiveParameter] private string $secret + ) {} public static function timecode(?int $timestamp = null): int { $timestamp ??= time(); @@ -22,7 +19,7 @@ class TOTPGenerator { public function generate(?int $timecode = null): string { $timecode ??= self::timecode(); - $hash = hash_hmac('sha1', pack('J', $timecode), Base32::decode($this->secretKey), true); + $hash = hash_hmac('sha1', pack('J', $timecode), $this->secret, true); $offset = ord($hash[strlen($hash) - 1]) & 0x0F; $bin = 0; diff --git a/src/Users/UserInfo.php b/src/Users/UserInfo.php index 0429e0cb..17572754 100644 --- a/src/Users/UserInfo.php +++ b/src/Users/UserInfo.php @@ -21,7 +21,6 @@ class UserInfo { public private(set) ?int $lastActiveTime, public private(set) ?int $deletedTime, public private(set) ?string $displayRoleId, - #[\SensitiveParameter] public private(set) ?string $totpKey, public private(set) ?string $aboutBody, public private(set) int $aboutBodyParser, public private(set) ?string $signatureBody, @@ -46,14 +45,13 @@ class UserInfo { lastActiveTime: $result->getIntegerOrNull(10), deletedTime: $result->getIntegerOrNull(11), displayRoleId: $result->getStringOrNull(12), - totpKey: $result->getStringOrNull(13), - aboutBody: $result->getStringOrNull(14), - aboutBodyParser: $result->getInteger(15), - signatureBody: $result->getStringOrNull(16), - signatureBodyParser: $result->getInteger(17), - birthdateRaw: $result->getStringOrNull(18), - backgroundSettings: $result->getIntegerOrNull(19), - title: $result->getString(20), + aboutBody: $result->getStringOrNull(13), + aboutBodyParser: $result->getInteger(14), + signatureBody: $result->getStringOrNull(15), + signatureBodyParser: $result->getInteger(16), + birthdateRaw: $result->getStringOrNull(17), + backgroundSettings: $result->getIntegerOrNull(18), + title: $result->getString(19), ); } @@ -93,10 +91,6 @@ class UserInfo { get => $this->deletedTime === null ? null : CarbonImmutable::createFromTimestampUTC($this->deletedTime); } - public bool $hasTOTP { - get => $this->totpKey !== null; - } - public bool $isAboutBodyPlain { get => $this->aboutBodyParser === Parser::PLAIN; } diff --git a/src/Users/UserTotpInfo.php b/src/Users/UserTotpInfo.php new file mode 100644 index 00000000..e8771ff5 --- /dev/null +++ b/src/Users/UserTotpInfo.php @@ -0,0 +1,35 @@ +<?php +namespace Misuzu\Users; + +use Carbon\CarbonImmutable; +use Index\Base32; +use Index\Db\DbResult; +use Misuzu\TotpGenerator; + +class UserTotpInfo { + public function __construct( + public private(set) ?string $userId, + #[\SensitiveParameter] public private(set) string $secret, + public private(set) int $createdTime + ) {} + + public static function fromResult(DbResult $result): UserTotpInfo { + return new UserTotpInfo( + userId: $result->getString(0), + secret: $result->getString(1), + createdTime: $result->getInteger(2), + ); + } + + public CarbonImmutable $createdAt { + get => CarbonImmutable::createFromTimestampUTC($this->createdTime); + } + + public string $encodedSecret { + get => Base32::encode($this->secret); + } + + public function createGenerator(): TotpGenerator { + return new TotpGenerator($this->secret); + } +} diff --git a/src/Users/UserTotpsData.php b/src/Users/UserTotpsData.php new file mode 100644 index 00000000..9d232e95 --- /dev/null +++ b/src/Users/UserTotpsData.php @@ -0,0 +1,64 @@ +<?php +namespace Misuzu\Users; + +use RuntimeException; +use Index\Db\{DbConnection,DbStatementCache}; + +class UserTotpsData { + private DbStatementCache $cache; + + public function __construct(DbConnection $dbConn) { + $this->cache = new DbStatementCache($dbConn); + } + + public function hasUserTotp(UserInfo|string $userInfo): bool { + $stmt = $this->cache->get(<<<SQL + SELECT COUNT(*) + FROM msz_users_totp + WHERE user_id = ? + SQL); + $stmt->nextParameter($userInfo instanceof UserInfo ? $userInfo->id : $userInfo); + $stmt->execute(); + + $result = $stmt->getResult(); + return $result->next() && $result->getBoolean(0); + } + + public function getUserTotp(UserInfo|string $userInfo): ?UserTotpInfo { + $stmt = $this->cache->get(<<<SQL + SELECT user_id, totp_secret, UNIX_TIMESTAMP(totp_created) + FROM msz_users_totp + WHERE user_id = ? + SQL); + $stmt->nextParameter($userInfo instanceof UserInfo ? $userInfo->id : $userInfo); + $stmt->execute(); + + $result = $stmt->getResult(); + return $result->next() ? UserTotpInfo::fromResult($result) : null; + } + + public function deleteUserTotp(UserInfo|string $userInfo): void { + $stmt = $this->cache->get(<<<SQL + DELETE FROM msz_users_totp + WHERE user_id = ? + SQL); + $stmt->nextParameter($userInfo instanceof UserInfo ? $userInfo->id : $userInfo); + $stmt->execute(); + } + + public function createUserTotp(UserInfo|string $userInfo): UserTotpInfo { + $stmt = $this->cache->get(<<<SQL + REPLACE INTO msz_users_totp ( + user_id, totp_secret + ) VALUES (?, RANDOM_BYTES(16)) + SQL); + $stmt->nextParameter($userInfo instanceof UserInfo ? $userInfo->id : $userInfo); + $stmt->execute(); + + $totpInfo = $this->getUserTotp($userInfo); + if($totpInfo === null) + throw new RuntimeException('failed to create totp'); + + return $totpInfo; + } +} diff --git a/src/Users/UsersContext.php b/src/Users/UsersContext.php index 31cfe2a8..9cf10327 100644 --- a/src/Users/UsersContext.php +++ b/src/Users/UsersContext.php @@ -10,6 +10,7 @@ class UsersContext { public private(set) BansData $bans; public private(set) WarningsData $warnings; public private(set) ModNotesData $modNotes; + public private(set) UserTotpsData $totps; /** @var array<string, UserInfo> */ private array $userInfos = []; @@ -29,6 +30,7 @@ class UsersContext { $this->bans = new BansData($dbConn); $this->warnings = new WarningsData($dbConn); $this->modNotes = new ModNotesData($dbConn); + $this->totps = new UserTotpsData($dbConn); } public function getUserInfo(string $value, int|string|null $select = null): UserInfo { diff --git a/src/Users/UsersData.php b/src/Users/UsersData.php index b2badfb3..b14557d3 100644 --- a/src/Users/UsersData.php +++ b/src/Users/UsersData.php @@ -169,7 +169,7 @@ class UsersData { UNIX_TIMESTAMP(user_created), UNIX_TIMESTAMP(user_active), UNIX_TIMESTAMP(user_deleted), - user_display_role_id, user_totp_key, user_about_content, + user_display_role_id, user_about_content, user_about_parser, user_signature_content, user_signature_parser, user_birthdate, user_background_settings, user_title @@ -267,7 +267,7 @@ class UsersData { UNIX_TIMESTAMP(user_created), UNIX_TIMESTAMP(user_active), UNIX_TIMESTAMP(user_deleted), - user_display_role_id, user_totp_key, user_about_content, + user_display_role_id, user_about_content, user_about_parser, user_signature_content, user_signature_parser, user_birthdate, user_background_settings, user_title @@ -339,7 +339,6 @@ class UsersData { ?string $countryCode = null, ?Colour $colour = null, RoleInfo|string|null $displayRoleInfo = null, - ?string $totpKey = null, ?string $aboutBody = null, ?int $aboutBodyParser = null, ?string $signatureBody = null, @@ -394,11 +393,6 @@ class UsersData { $values[] = $displayRoleInfo; } - if($totpKey !== null) { - $fields[] = 'user_totp_key = ?'; - $values[] = $totpKey === '' ? null : $totpKey; - } - if($aboutBody !== null && $aboutBodyParser !== null) { if(self::validateProfileAbout($aboutBodyParser, $aboutBody) !== '') throw new InvalidArgumentException('$aboutBody and $aboutBodyParser contain invalid data!'); diff --git a/templates/settings/account.twig b/templates/settings/account.twig index d281b0ed..ea756632 100644 --- a/templates/settings/account.twig +++ b/templates/settings/account.twig @@ -130,7 +130,7 @@ {% endif %} <div class="settings__two-factor__settings"> - {% if settings_user.hasTOTP %} + {% if has_totp %} <div class="settings__two-factor__settings__status"> <i class="fas fa-lock fa-fw"></i> Two Factor Authentication is enabled! </div>