Moved passwords out of the users table.

This commit is contained in:
flash 2025-02-09 00:26:12 +00:00
parent 7f85abba6e
commit f39e1230c5
13 changed files with 202 additions and 97 deletions

View file

@ -0,0 +1,33 @@
<?php
use Index\Db\DbConnection;
use Index\Db\Migration\DbMigration;
final class MovedPasswordsOutOfTheUserTable_20250208_235046 implements DbMigration {
public function migrate(DbConnection $conn): void {
$conn->execute(<<<SQL
CREATE TABLE msz_users_passwords (
user_id INT(10) UNSIGNED NOT NULL,
password_hash VARCHAR(255) NOT NULL COLLATE 'ascii_bin',
password_created TIMESTAMP NOT NULL DEFAULT current_timestamp(),
PRIMARY KEY (user_id),
CONSTRAINT users_passwords_users_foreign
FOREIGN KEY (user_id)
REFERENCES msz_users (user_id)
ON UPDATE CASCADE
ON DELETE CASCADE
) COLLATE='utf8mb4_bin';
SQL);
$conn->execute(<<<SQL
INSERT msz_users_passwords
SELECT user_id, user_password, NOW()
FROM msz_users
WHERE user_password IS NOT NULL AND TRIM(user_password) <> ''
SQL);
$conn->execute(<<<SQL
ALTER TABLE msz_users
DROP COLUMN user_password;
SQL);
}
}

View file

@ -98,19 +98,26 @@ while(!empty($_POST['login']) && is_array($_POST['login'])) {
break;
}
if(!$userInfo->hasPasswordHash) {
$notices[] = 'Your password has been invalidated, please reset it.';
break;
}
if($userInfo->deleted || !$userInfo->verifyPassword($_POST['login']['password'])) {
if($userInfo->deleted) {
$msz->authCtx->loginAttempts->recordAttempt(false, $ipAddress, $countryCode, $userAgent, $clientInfo, $userInfo);
$notices[] = $loginFailedError;
break;
}
if($userInfo->passwordNeedsRehash)
$msz->usersCtx->users->updateUser($userInfo, password: $_POST['login']['password']);
$pwInfo = $msz->usersCtx->passwords->getUserPassword($userInfo);
if($pwInfo === null) {
$notices[] = 'Your password has been invalidated, please reset it.';
break;
}
if(!$pwInfo->verifyPassword($_POST['login']['password'])) {
$msz->authCtx->loginAttempts->recordAttempt(false, $ipAddress, $countryCode, $userAgent, $clientInfo, $userInfo);
$notices[] = $loginFailedError;
break;
}
if($pwInfo->needsRehash)
$msz->usersCtx->passwords->updateUserPassword($userInfo, $_POST['login']['password']);
if(!empty($loginPermCat) && $loginPermVal > 0 && !$msz->perms->checkPermissions($loginPermCat, $loginPermVal, $userInfo)) {
$notices[] = "Login succeeded, but you're not allowed to browse the site right now.";

View file

@ -2,7 +2,7 @@
namespace Misuzu;
use RuntimeException;
use Misuzu\Users\User;
use Misuzu\Users\{User,UserPasswordsData};
if(!isset($msz) || !($msz instanceof \Misuzu\MisuzuContext))
die('Script must be called through the Misuzu route dispatcher.');
@ -63,15 +63,15 @@ while($canResetPassword) {
break;
}
$passwordValidation = $msz->usersCtx->users->validatePassword($passwordNew);
$passwordValidation = UserPasswordsData::validateUserPassword($passwordNew);
if($passwordValidation !== '') {
$notices[] = $msz->usersCtx->users->validatePasswordText($passwordValidation);
$notices[] = UserPasswordsData::validateUserPasswordText($passwordValidation);
break;
}
// 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);
$msz->usersCtx->passwords->updateUserPassword($userInfo, $passwordNew);
$msz->usersCtx->totps->deleteUserTotp($userInfo);
$msz->createAuditLog('PASSWORD_RESET', [], $userInfo);

View file

@ -2,7 +2,7 @@
namespace Misuzu;
use RuntimeException;
use Misuzu\Users\User;
use Misuzu\Users\{User,UserPasswordsData};
if(!isset($msz) || !($msz instanceof \Misuzu\MisuzuContext))
die('Script must be called through the Misuzu route dispatcher.');
@ -61,11 +61,11 @@ while(!empty($register)) {
$notices[] = $msz->usersCtx->users->validateEMailAddressText($emailValidation);
if($register['password_confirm'] !== $register['password'])
$notices[] = 'The given passwords don\'t match.';
$notices[] = "The given passwords don't match.";
$passwordValidation = $msz->usersCtx->users->validatePassword($register['password']);
$passwordValidation = UserPasswordsData::validateUserPassword($register['password']);
if($passwordValidation !== '')
$notices[] = $msz->usersCtx->users->validatePasswordText($passwordValidation);
$notices[] = UserPasswordsData::validateUserPasswordText($passwordValidation);
if(!empty($notices))
break;
@ -75,12 +75,12 @@ while(!empty($register)) {
try {
$userInfo = $msz->usersCtx->users->createUser(
$register['username'],
$register['password'],
$register['email'],
$ipAddress,
$countryCode,
$defaultRoleInfo
);
$msz->usersCtx->passwords->updateUserPassword($userInfo, $register['password']);
} catch(RuntimeException $ex) {
$notices[] = 'Something went wrong while creating your account, please alert an administrator or a developer about this!';
break;

View file

@ -5,7 +5,7 @@ use RuntimeException;
use Index\Colour\Colour;
use Misuzu\Perm;
use Misuzu\Auth\AuthTokenCookie;
use Misuzu\Users\User;
use Misuzu\Users\{User,UserPasswordsData};
if(!isset($msz) || !($msz instanceof \Misuzu\MisuzuContext))
die('Script must be called through the Misuzu route dispatcher.');
@ -190,13 +190,13 @@ if(CSRF::validateRequest() && $canEdit) {
if($passwordNewValue !== $passwordConfirmValue)
$notices[] = 'Confirm password does not match.';
else {
$passwordValidation = $msz->usersCtx->users->validatePassword($passwordNewValue);
$passwordValidation = UserPasswordsData::validateUserPassword($passwordNewValue);
if($passwordValidation !== '')
$notices[] = $msz->usersCtx->users->validatePasswordText($passwordValidation);
$notices[] = UserPasswordsData::validateUserPasswordText($passwordValidation);
}
if(empty($notices))
$msz->usersCtx->users->updateUser(userInfo: $userInfo, password: $passwordNewValue);
$msz->usersCtx->passwords->updateUserPassword($userInfo, $passwordNewValue);
}
}

View file

@ -2,7 +2,7 @@
namespace Misuzu;
use RuntimeException;
use Misuzu\Users\User;
use Misuzu\Users\{User,UserPasswordsData};
use chillerlan\QRCode\QRCode;
use chillerlan\QRCode\QROptions;
@ -50,7 +50,7 @@ if(!$isRestricted && $isVerifiedRequest && !empty($_POST['role'])) {
if($isVerifiedRequest && isset($_POST['tfa']['enable']) && $msz->usersCtx->totps->hasUserTotp($userInfo) !== (bool)$_POST['tfa']['enable']) {
if((bool)$_POST['tfa']['enable']) {
$totpInfo = $msz->usersCtx->totps->createUserTotp($userInfo);
$totpInfo = $msz->usersCtx->totps->updateUserTotp($userInfo);
$totpSecret = $totpInfo->encodedSecret;
$totpIssuer = $msz->siteInfo->name;
$totpQrcode = (new QRCode(new QROptions([
@ -74,7 +74,7 @@ if($isVerifiedRequest && isset($_POST['tfa']['enable']) && $msz->usersCtx->totps
}
if($isVerifiedRequest && !empty($_POST['current_password'])) {
if(!$userInfo->verifyPassword($_POST['current_password'] ?? '')) {
if(!$msz->usersCtx->passwords->getUserPassword($userInfo)?->verifyPassword($_POST['current_password'] ?? '')) {
$errors[] = 'Your password was incorrect.';
} else {
// Changing e-mail
@ -100,12 +100,12 @@ if($isVerifiedRequest && !empty($_POST['current_password'])) {
if(empty($_POST['password']['confirm']) || $_POST['password']['new'] !== $_POST['password']['confirm']) {
$errors[] = 'The new passwords you entered did not match each other.';
} else {
$checkPassword = $msz->usersCtx->users->validatePassword($_POST['password']['new']);
$checkPassword = UserPasswordsData::validateUserPassword($_POST['password']['new']);
if($checkPassword !== '') {
$errors[] = $msz->usersCtx->users->validatePasswordText($checkPassword);
$errors[] = UserPasswordsData::validateUserPasswordText($checkPassword);
} else {
$msz->usersCtx->users->updateUser(userInfo: $userInfo, password: $_POST['password']['new']);
$msz->usersCtx->passwords->updateUserPassword($userInfo, $_POST['password']['new']);
$msz->createAuditLog('PERSONAL_PASSWORD_CHANGE');
}
}

View file

@ -111,7 +111,7 @@ $userInfo = $msz->authInfo->userInfo;
if(isset($_POST['action']) && is_string($_POST['action'])) {
if(isset($_POST['password']) && is_string($_POST['password'])
&& ($userInfo->verifyPassword($_POST['password'] ?? ''))) {
&& ($msz->usersCtx->passwords->getUserPassword($userInfo)?->verifyPassword($_POST['password'] ?? ''))) {
switch($_POST['action']) {
case 'data':
$msz->createAuditLog('PERSONAL_DATA_DOWNLOAD');
@ -151,9 +151,10 @@ if(isset($_POST['action']) && is_string($_POST['action'])) {
$tmpFiles[] = db_to_zip($archive, $userInfo, 'profile_backgrounds', ['user_id:s', 'bg_attach:s', 'bg_blend:i', 'bg_slide: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_title:s:n', 'user_display_role_id:s:n', 'user_created:t', 'user_active:t:n', 'user_deleted:t:n']);
$tmpFiles[] = db_to_zip($archive, $userInfo, 'users', ['user_id:s', 'user_name:s', 'user_email:s', 'user_remote_addr_first:a', 'user_remote_addr_last:a', 'user_super:b', 'user_country:s', 'user_colour:i:n', 'user_title:s:n', 'user_display_role_id:s:n', 'user_created:t', 'user_active:t:n', 'user_deleted:t: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_birthdates', ['user_id:s', 'birth_year:i:n', 'birth_month:i', 'birth_day:i']);
$tmpFiles[] = db_to_zip($archive, $userInfo, 'users_passwords', ['user_id:s', 'password_hash:n', 'password_created:t']);
$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_roles', ['user_id:s', 'role_id:s']);
$tmpFiles[] = db_to_zip($archive, $userInfo, 'users_totp', ['user_id:s', 'totp_secret:n', 'totp_created:t']);

View file

@ -9,7 +9,6 @@ class UserInfo {
public function __construct(
public private(set) string $id,
public private(set) string $name,
#[\SensitiveParameter] private ?string $passwordHash,
#[\SensitiveParameter] public private(set) string $emailAddress,
public private(set) string $registerRemoteAddress,
public private(set) string $lastRemoteAddress,
@ -27,33 +26,20 @@ class UserInfo {
return new UserInfo(
id: $result->getString(0),
name: $result->getString(1),
passwordHash: $result->getStringOrNull(2),
emailAddress: $result->getString(3),
registerRemoteAddress: $result->getString(4),
lastRemoteAddress: $result->getStringOrNull(5),
super: $result->getBoolean(6),
countryCode: $result->getString(7),
colourRaw: $result->getIntegerOrNull(8),
title: $result->getString(9),
displayRoleId: $result->getStringOrNull(10),
createdTime: $result->getInteger(11),
lastActiveTime: $result->getIntegerOrNull(12),
deletedTime: $result->getIntegerOrNull(13),
emailAddress: $result->getString(2),
registerRemoteAddress: $result->getString(3),
lastRemoteAddress: $result->getStringOrNull(4),
super: $result->getBoolean(5),
countryCode: $result->getString(6),
colourRaw: $result->getIntegerOrNull(7),
title: $result->getString(8),
displayRoleId: $result->getStringOrNull(9),
createdTime: $result->getInteger(10),
lastActiveTime: $result->getIntegerOrNull(11),
deletedTime: $result->getIntegerOrNull(12),
);
}
public bool $hasPasswordHash {
get => $this->passwordHash !== null && $this->passwordHash !== '';
}
public bool $passwordNeedsRehash {
get => $this->hasPasswordHash && UsersData::passwordNeedsRehash($this->passwordHash);
}
public function verifyPassword(string $password): bool {
return $this->hasPasswordHash && password_verify($password, $this->passwordHash);
}
public bool $hasColour {
get => $this->colourRaw !== null && ($this->colourRaw & 0x40000000) === 0;
}

View file

@ -0,0 +1,33 @@
<?php
namespace Misuzu\Users;
use Carbon\CarbonImmutable;
use Index\Db\DbResult;
class UserPasswordInfo {
public function __construct(
public private(set) string $userId,
#[\SensitiveParameter] private string $hash,
public private(set) int $createdTime
) {}
public static function fromResult(DbResult $result): UserPasswordInfo {
return new UserPasswordInfo(
userId: $result->getString(0),
hash: $result->getString(1),
createdTime: $result->getInteger(2),
);
}
public bool $needsRehash {
get => UserPasswordsData::passwordNeedsRehash($this->hash);
}
public CarbonImmutable $createdAt {
get => CarbonImmutable::createFromTimestampUTC($this->createdTime);
}
public function verifyPassword(#[\SensitiveParameter] string $password): bool {
return password_verify($password, $this->hash);
}
}

View file

@ -0,0 +1,80 @@
<?php
namespace Misuzu\Users;
use RuntimeException;
use Index\XString;
use Index\Db\{DbConnection,DbStatementCache};
class UserPasswordsData {
public const string PASSWORD_ALGO = PASSWORD_ARGON2ID;
public const array PASSWORD_OPTS = [];
public const int PASSWORD_UNIQUE = 6;
private DbStatementCache $cache;
public function __construct(DbConnection $dbConn) {
$this->cache = new DbStatementCache($dbConn);
}
public static function passwordHash(#[\SensitiveParameter] string $password): string {
return password_hash($password, self::PASSWORD_ALGO, self::PASSWORD_OPTS);
}
public static function passwordNeedsRehash(#[\SensitiveParameter] string $passwordHash): bool {
return password_needs_rehash($passwordHash, self::PASSWORD_ALGO, self::PASSWORD_OPTS);
}
public function getUserPassword(UserInfo|string $userInfo): ?UserPasswordInfo {
$stmt = $this->cache->get(<<<SQL
SELECT user_id, password_hash, UNIX_TIMESTAMP(password_created)
FROM msz_users_passwords
WHERE user_id = ?
SQL);
$stmt->nextParameter($userInfo instanceof UserInfo ? $userInfo->id : $userInfo);
$stmt->execute();
$result = $stmt->getResult();
return $result->next() ? UserPasswordInfo::fromResult($result) : null;
}
public function deleteUserPassword(UserInfo|string $userInfo): void {
$stmt = $this->cache->get(<<<SQL
DELETE FROM msz_users_passwords
WHERE user_id = ?
SQL);
$stmt->nextParameter($userInfo instanceof UserInfo ? $userInfo->id : $userInfo);
$stmt->execute();
}
public function updateUserPassword(UserInfo|string $userInfo, #[\SensitiveParameter] string $password): UserPasswordInfo {
$stmt = $this->cache->get(<<<SQL
REPLACE INTO msz_users_passwords (
user_id, password_hash
) VALUES (?, ?)
SQL);
$stmt->nextParameter($userInfo instanceof UserInfo ? $userInfo->id : $userInfo);
$stmt->nextParameter(self::passwordHash($password));
$stmt->execute();
$pwInfo = $this->getUserPassword($userInfo);
if($pwInfo === null)
throw new RuntimeException('failed to create password');
return $pwInfo;
}
public static function validateUserPassword(string $password): string {
if(XString::countUnique($password) < self::PASSWORD_UNIQUE)
return 'weak';
return '';
}
public static function validateUserPasswordText(string $error): string {
return match($error) {
'weak' => sprintf("Your password is too weak, it must contain at least %d unique characters.", self::PASSWORD_UNIQUE),
'' => 'Your password is strong enough, why are you seeing this?',
default => 'Your password is not acceptable.',
};
}
}

View file

@ -46,7 +46,7 @@ class UserTotpsData {
$stmt->execute();
}
public function createUserTotp(UserInfo|string $userInfo): UserTotpInfo {
public function updateUserTotp(UserInfo|string $userInfo): UserTotpInfo {
$stmt = $this->cache->get(<<<SQL
REPLACE INTO msz_users_totp (
user_id, totp_secret

View file

@ -10,6 +10,7 @@ class UsersContext {
public private(set) BansData $bans;
public private(set) WarningsData $warnings;
public private(set) ModNotesData $modNotes;
public private(set) UserPasswordsData $passwords;
public private(set) UserTotpsData $totps;
public private(set) UserBirthdatesData $birthdates;
@ -31,6 +32,7 @@ class UsersContext {
$this->bans = new BansData($dbConn);
$this->warnings = new WarningsData($dbConn);
$this->modNotes = new ModNotesData($dbConn);
$this->passwords = new UserPasswordsData($dbConn);
$this->totps = new UserTotpsData($dbConn);
$this->birthdates = new UserBirthdatesData($dbConn);
}

View file

@ -16,20 +16,8 @@ class UsersData {
$this->cache = new DbStatementCache($dbConn);
}
public const NAME_MIN_LENGTH = 3;
public const NAME_MAX_LENGTH = 16;
public const PASSWORD_ALGO = PASSWORD_ARGON2ID;
public const PASSWORD_OPTS = [];
public const PASSWORD_UNIQUE = 6;
public static function passwordHash(string $password): string {
return password_hash($password, self::PASSWORD_ALGO, self::PASSWORD_OPTS);
}
public static function passwordNeedsRehash(string $passwordHash): bool {
return password_needs_rehash($passwordHash, self::PASSWORD_ALGO, self::PASSWORD_OPTS);
}
public const int NAME_MIN_LENGTH = 3;
public const int NAME_MAX_LENGTH = 16;
public function countUsers(
RoleInfo|string|null $roleInfo = null,
@ -165,7 +153,7 @@ class UsersData {
$args = 0;
$query = <<<SQL
SELECT u.user_id, u.user_name, u.user_password, u.user_email,
SELECT u.user_id, u.user_name, u.user_email,
INET6_NTOA(u.user_remote_addr_first), INET6_NTOA(u.user_remote_addr_last),
u.user_super, u.user_country, u.user_colour,
u.user_title, u.user_display_role_id,
@ -271,7 +259,7 @@ class UsersData {
$args = 0;
$query = <<<SQL
SELECT user_id, user_name, user_password, user_email,
SELECT user_id, user_name, user_email,
INET6_NTOA(user_remote_addr_first), INET6_NTOA(user_remote_addr_last),
user_super, user_country, user_colour,
user_title, user_display_role_id,
@ -307,7 +295,6 @@ class UsersData {
public function createUser(
string $name,
string $password,
string $email,
string $remoteAddr,
string $countryCode,
@ -318,16 +305,13 @@ class UsersData {
elseif($displayRoleInfo === null)
$displayRoleInfo = RolesData::DEFAULT_ROLE;
$password = self::passwordHash($password);
if(self::validateName($name, true) !== '')
throw new InvalidArgumentException('$name is not a valid user name.');
if(self::validateEMailAddress($email, true) !== '')
throw new InvalidArgumentException('$email is not a valid e-mail address.');
$stmt = $this->cache->get('INSERT INTO msz_users (user_name, user_password, user_email, user_remote_addr_first, user_remote_addr_last, user_country, user_display_role_id) VALUES (?, ?, ?, INET6_ATON(?), INET6_ATON(?), ?, ?)');
$stmt = $this->cache->get('INSERT INTO msz_users (user_name, user_email, user_remote_addr_first, user_remote_addr_last, user_country, user_display_role_id) VALUES (?, ?, INET6_ATON(?), INET6_ATON(?), ?, ?)');
$stmt->nextParameter($name);
$stmt->nextParameter($password);
$stmt->nextParameter($email);
$stmt->nextParameter($remoteAddr);
$stmt->nextParameter($remoteAddr);
@ -342,7 +326,6 @@ class UsersData {
UserInfo|string $userInfo,
?string $name = null,
?string $emailAddr = null,
?string $password = null,
?string $countryCode = null,
?Colour $colour = null,
RoleInfo|string|null $displayRoleInfo = null,
@ -372,11 +355,6 @@ class UsersData {
$values[] = $emailAddr;
}
if($password !== null) {
$fields[] = 'password = ?';
$values[] = $password === '' ? null : self::passwordHash($password);
}
if($countryCode !== null) {
$fields[] = 'user_country = ?';
$values[] = $countryCode;
@ -643,19 +621,4 @@ class UsersData {
default => 'Your e-mail address is not correctly formatted.',
};
}
public static function validatePassword(string $password): string {
if(XString::countUnique($password) < self::PASSWORD_UNIQUE)
return 'weak';
return '';
}
public static function validatePasswordText(string $error): string {
return match($error) {
'weak' => sprintf("Your password is too weak, it must contain at least %d unique characters.", self::PASSWORD_UNIQUE),
'' => 'Your password is strong enough, why are you seeing this?',
default => 'Your password is not acceptable.',
};
}
}