diff --git a/database/.gitkeep b/database/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/database/2023_01_09_213225_create_users_tables.php b/database/2023_01_09_213225_create_users_tables.php new file mode 100644 index 0000000..815b2c9 --- /dev/null +++ b/database/2023_01_09_213225_create_users_tables.php @@ -0,0 +1,104 @@ +execute(' + CREATE TABLE hau_users ( + user_id INT(10) UNSIGNED NOT NULL AUTO_INCREMENT, + user_name VARCHAR(255) NOT NULL COLLATE \'ascii_general_ci\', + user_country CHAR(2) NOT NULL DEFAULT \'XX\' COLLATE \'ascii_general_ci\', + user_colour INT(10) UNSIGNED NULL DEFAULT NULL, + user_super TINYINT(1) UNSIGNED NOT NULL DEFAULT \'0\', + user_time_zone VARBINARY(255) NOT NULL DEFAULT \'UTC\', + user_created TIMESTAMP NOT NULL DEFAULT current_timestamp(), + user_updated TIMESTAMP NOT NULL DEFAULT current_timestamp() ON UPDATE current_timestamp(), + user_deleted TIMESTAMP NULL DEFAULT NULL, + PRIMARY KEY (user_id) USING BTREE, + UNIQUE INDEX hau_users_name_unique (user_name) USING BTREE, + INDEX hau_users_created_index (user_created) USING BTREE, + INDEX hau_users_deleted_index (user_deleted) USING BTREE + ) ENGINE=InnoDB COLLATE=utf8mb4_bin; + '); + + $conn->execute(' + CREATE TABLE hau_users_auth ( + user_id INT(10) UNSIGNED NOT NULL, + user_auth_type VARCHAR(32) NOT NULL COLLATE \'ascii_general_ci\', + user_auth_enabled TIMESTAMP NOT NULL DEFAULT current_timestamp(), + PRIMARY KEY (user_id, user_auth_type) USING BTREE, + INDEX hau_users_auth_user_foreign (user_id) USING BTREE, + CONSTRAINT hau_users_auth_user_foreign + FOREIGN KEY (user_id) + REFERENCES hau_users (user_id) + ON UPDATE CASCADE + ON DELETE CASCADE + ) ENGINE=InnoDB COLLATE=utf8mb4_bin; + '); + + $conn->execute(' + CREATE TABLE hau_users_backup ( + user_id INT(10) UNSIGNED NOT NULL, + user_backup_code BINARY(8) NOT NULL, + user_backup_created TIMESTAMP NOT NULL DEFAULT current_timestamp(), + user_backup_used TIMESTAMP NULL DEFAULT NULL, + PRIMARY KEY (user_id, user_backup_code) USING BTREE, + INDEX hau_users_backup_used_index (user_backup_used) USING BTREE, + CONSTRAINT hau_users_backup_user_foreign + FOREIGN KEY (user_id) + REFERENCES hau_users (user_id) + ON UPDATE CASCADE + ON DELETE CASCADE + ) ENGINE=InnoDB COLLATE=utf8mb4_bin; + '); + + $conn->execute(' + CREATE TABLE hau_users_emails ( + user_email_address VARCHAR(255) NOT NULL COLLATE \'ascii_general_ci\', + user_id INT(10) UNSIGNED NOT NULL, + user_email_created TIMESTAMP NOT NULL DEFAULT current_timestamp(), + user_email_verified TIMESTAMP NULL DEFAULT NULL, + user_email_recovery TINYINT(1) UNSIGNED NOT NULL DEFAULT \'0\', + PRIMARY KEY (user_email_address) USING BTREE, + UNIQUE INDEX hau_users_emails_user_foreign (user_id) USING BTREE, + INDEX hau_users_emails_created_index (user_email_created) USING BTREE, + INDEX hau_users_emails_recovery_index (user_email_recovery) USING BTREE, + INDEX hau_users_emails_verified_index (user_email_verified) USING BTREE, + CONSTRAINT hau_users_emails_user_foreign + FOREIGN KEY (user_id) + REFERENCES hau_users (user_id) + ON UPDATE CASCADE + ON DELETE CASCADE + ) ENGINE=InnoDB COLLATE=utf8mb4_bin; + '); + + $conn->execute(' + CREATE TABLE hau_users_passwords ( + user_id INT(10) UNSIGNED NOT NULL, + user_password_hash VARBINARY(255) NOT NULL, + user_password_changed TIMESTAMP NOT NULL DEFAULT current_timestamp() ON UPDATE current_timestamp(), + UNIQUE INDEX hau_users_passwords_user_foreign (user_id) USING BTREE, + CONSTRAINT hau_users_passwords_user_foreign + FOREIGN KEY (user_id) + REFERENCES hau_users (user_id) + ON UPDATE CASCADE + ON DELETE CASCADE + ) ENGINE=InnoDB COLLATE=utf8mb4_bin; + '); + + $conn->execute(' + CREATE TABLE hau_users_totp ( + user_id INT(10) UNSIGNED NOT NULL, + user_totp_key BINARY(26) NOT NULL, + user_totp_changed TIMESTAMP NOT NULL DEFAULT current_timestamp() ON UPDATE current_timestamp(), + UNIQUE INDEX hau_users_totp_user_foreign (user_id) USING BTREE, + CONSTRAINT hau_users_totp_user_foreign + FOREIGN KEY (user_id) + REFERENCES hau_users (user_id) + ON UPDATE CASCADE + ON DELETE CASCADE + ) ENGINE=InnoDB COLLATE=utf8mb4_bin; + '); + } +} diff --git a/hanyuu.php b/hanyuu.php index da8ea05..f75dcb5 100644 --- a/hanyuu.php +++ b/hanyuu.php @@ -3,6 +3,8 @@ namespace Hanyuu; use Index\Autoloader; use Index\Environment; +use Index\Data\DbTools; +use Hanyuu\Config\IConfig; use Hanyuu\Config\ArrayConfig; define('HAU_STARTUP', microtime(true)); @@ -44,5 +46,9 @@ set_exception_handler(function(\Throwable $ex) { die('

Hanyuu is sad.

'); }); -$hau = new HanyuuContext(ArrayConfig::open(HAU_DIR_CONFIG . '/config.ini')); -$hau->connectDb(); +$cfg = ArrayConfig::open(HAU_DIR_CONFIG . '/config.ini'); + +$dbc = DbTools::create($cfg->getValue('database:dsn', IConfig::T_STR, 'null')); +$dbc->execute('SET SESSION time_zone = \'+00:00\', sql_mode = \'STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION\';'); + +$hau = new HanyuuContext($cfg, $dbc); diff --git a/src/HanyuuContext.php b/src/HanyuuContext.php index f64ff92..ead856c 100644 --- a/src/HanyuuContext.php +++ b/src/HanyuuContext.php @@ -2,7 +2,6 @@ namespace Hanyuu; use Index\Data\IDbConnection; -use Index\Data\DbTools; use Index\Data\Migration\IDbMigrationRepo; use Index\Data\Migration\DbMigrationManager; use Index\Data\Migration\FsDbMigrationRepo; @@ -11,29 +10,30 @@ use Index\Http\HttpRequest; use Index\Routing\IRouter; use Hanyuu\Config\IConfig; use Hanyuu\Templating\TemplateContext; +use Hanyuu\Users\IUsers; +use Hanyuu\Users\Db\DbUsers; class HanyuuContext { - private const DB_INIT = 'SET SESSION time_zone = \'+00:00\', sql_mode = \'STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION\';'; - private IConfig $config; private IDbConnection $dbConn; + private IUsers $users; private ?TemplateContext $tpl = null; - public function __construct(IConfig $config) { + public function __construct(IConfig $config, IDbConnection $dbConn) { $this->config = $config; + $this->dbConn = $dbConn; + $this->users = new DbUsers($dbConn); } public function getSiteName(): string { return $this->config->getValue('site:name', IConfig::T_STR, 'Hanyuu'); } - public function connectDb(?IDbConnection $dbConn = null): void { - $dbConn ??= DbTools::create($this->config->getValue('database:dsn', IConfig::T_STR, 'null')); - $dbConn->execute(self::DB_INIT); - $this->dbConn = $dbConn; + public function getUsers(): IUsers { + return $this->users; } - public function getDb(): IDbConnection { + public function getDatabase(): IDbConnection { return $this->dbConn; } @@ -106,5 +106,26 @@ class HanyuuContext { $this->router->get('/', function($response, $request) { return 503; }); + + if(!HAU_DEBUG) + return; + + $this->router->get('/test', function() { + $users = $this->getUsers(); + $userInfo = $users->getUserInfoByName('flAsH'); + + $ffa = $users->getUserFirstFactorAuthInfos($userInfo); + $tfa = $users->getUserSecondFactorAuthInfos($userInfo); + + $pwdInfo = $users->getUserPasswordInfo($userInfo); + + $totpInfo = $users->getUserTOTPInfo($userInfo); + $totpGen = $totpInfo->createGenerator(); + + $emailInfos = $users->getUserEMailInfos($userInfo); + + $backupInfos = $users->getUserBackupInfos($userInfo); + + }); } } diff --git a/src/OTP/IOTPGenerator.php b/src/OTP/IOTPGenerator.php new file mode 100644 index 0000000..af07b90 --- /dev/null +++ b/src/OTP/IOTPGenerator.php @@ -0,0 +1,6 @@ +secretKey)) + throw new InvalidArgumentException('$secretKey may not be empty'); + if($this->digits < 1) + throw new InvalidArgumentException('$digits must be a positive integer'); + if($this->interval < 1) + throw new InvalidArgumentException('$interval must be a positive integer'); + if(!in_array($this->hashAlgo, hash_hmac_algos(), true)) + throw new InvalidArgumentException('$hashAlgo must be a hashing algorithm suitable for hmac'); + } + + public function getDigits(): int { + return $this->digits; + } + + public function getInterval(): int { + return $this->interval; + } + + public function getHashAlgo(): string { + return $this->hashAlgo; + } + + public function getTimeCode(int $offset = 0, int $timeStamp = -1): int { + if($timeStamp < 0) + $timeStamp = time(); + + // use -1 and 1 to get the previous and next token for user convenience + if($offset !== 0) + $timeStamp += $this->interval * $offset; + + return (int)(($timeStamp * 1000) / ($this->interval * 1000)); + } + + public function generate(int $offset = 0, int $timeStamp = -1): string { + $timeCode = pack('J', $this->getTimeCode($offset, $timeStamp)); + $secretKey = Serialiser::base32()->deserialise($this->secretKey); + $hash = hash_hmac($this->hashAlgo, $timeCode, $secretKey, true); + + $offset = ord($hash[strlen($hash) - 1]) & 0x0F; + + $bin = (ord($hash[$offset]) & 0x7F) << 24; + $bin |= (ord($hash[$offset + 1]) & 0xFF) << 16; + $bin |= (ord($hash[$offset + 2]) & 0xFF) << 8; + $bin |= ord($hash[$offset + 3]) & 0xFF; + + $otp = (string)($bin % pow(10, $this->digits)); + + return str_pad($otp, $this->digits, '0', STR_PAD_LEFT); + } + + public static function generateKey(int $bytes = 16): string { + if($length < 1) + throw new InvalidArgumentException('$bytes must be a positive integer'); + + return Serialiser::base32()->serialise(random_bytes($bytes)); + } +} diff --git a/src/Templating/Template.php b/src/Templating/Template.php index a487c4b..2e1e141 100644 --- a/src/Templating/Template.php +++ b/src/Templating/Template.php @@ -3,6 +3,9 @@ namespace Hanyuu\Templating; use RuntimeException; +// this entire thing needs to be redone and integrated into Index +// take this project as an opportunity to do that + class Template { public function __construct( private TemplateContext $context, diff --git a/src/Users/Db/DbUserAuthInfo.php b/src/Users/Db/DbUserAuthInfo.php new file mode 100644 index 0000000..d8269b0 --- /dev/null +++ b/src/Users/Db/DbUserAuthInfo.php @@ -0,0 +1,30 @@ +userId = $result->getString(0); + $this->type = $result->getString(1); + $this->enabled = DateTime::fromUnixTimeSeconds($result->getInteger(2)); + } + + public function getUserId(): string { + return $this->userId; + } + + public function getType(): string { + return $this->type; + } + + public function getEnabledTime(): DateTime { + return $this->enabled; + } +} diff --git a/src/Users/Db/DbUserBackupInfo.php b/src/Users/Db/DbUserBackupInfo.php new file mode 100644 index 0000000..cf7ee10 --- /dev/null +++ b/src/Users/Db/DbUserBackupInfo.php @@ -0,0 +1,42 @@ +userId = $result->getString(0); + $this->code = $result->getString(1); + $this->created = DateTime::fromUnixTimeSeconds($result->getInteger(2)); + $this->isUsed = !$result->isNull(3); + $this->used = DateTime::fromUnixTimeSeconds($result->isNull(3) ? 0 : $result->getInteger(3)); + } + + public function getUserId(): string { + return $this->userId; + } + + public function getCode(): string { + return $this->code; + } + + public function getCreatedTime(): DateTime { + return $this->created; + } + + public function isUsed(): bool { + return $this->isUsed; + } + + public function getUsedTime(): DateTime { + return $this->used; + } +} diff --git a/src/Users/Db/DbUserEMailInfo.php b/src/Users/Db/DbUserEMailInfo.php new file mode 100644 index 0000000..5a3a500 --- /dev/null +++ b/src/Users/Db/DbUserEMailInfo.php @@ -0,0 +1,48 @@ +userId = $result->getString(0); + $this->address = $result->getString(1); + $this->created = DateTime::fromUnixTimeSeconds($result->getInteger(2)); + $this->isVerified = !$result->isNull(3); + $this->verified = DateTime::fromUnixTimeSeconds($result->isNull(3) ? 0 : $result->getInteger(3)); + $this->isRecovery = $result->getInteger(4) !== 0; + } + + public function getUserId(): string { + return $this->userId; + } + + public function getAddress(): string { + return $this->address; + } + + public function getCreatedTime(): DateTime { + return $this->created; + } + + public function isVerified(): bool { + return $this->isVerified; + } + + public function getVerifiedTime(): DateTime { + return $this->verified; + } + + public function isRecovery(): bool { + return $this->isRecovery; + } +} diff --git a/src/Users/Db/DbUserInfo.php b/src/Users/Db/DbUserInfo.php new file mode 100644 index 0000000..39a0c60 --- /dev/null +++ b/src/Users/Db/DbUserInfo.php @@ -0,0 +1,75 @@ +id = $result->getString(0); + $this->name = $result->getString(1); + $this->country = $result->getString(2); + $this->colour = $result->isNull(3) ? Colour::none() : ColourRGB::fromRawRGB($result->getInteger(3)); + $this->isSuper = $result->getInteger(4) !== 0; + $this->timeZone = new TimeZoneInfo($result->getString(5)); + $this->created = DateTime::fromUnixTimeSeconds($result->getInteger(6)); + $this->updated = DateTime::fromUnixTimeSeconds($result->getInteger(7)); + $this->isDeleted = !$result->isNull(8); + $this->deleted = DateTime::fromUnixTimeSeconds($result->isNull(8) ? 0 : $result->getInteger(8)); + } + + public function getId(): string { + return $this->id; + } + + public function getName(): string { + return $this->name; + } + + public function getCountryCode(): string { + return $this->country; + } + + public function getColour(): Colour { + return $this->colour; + } + + public function isSuper(): bool { + return $this->isSuper; + } + + public function getTimeZone(): TimeZoneInfo { + return $this->timeZone; + } + + public function getCreatedTime(): DateTime { + return $this->created; + } + + public function getUpdatedTime(): DateTime { + return $this->updated; + } + + public function isDeleted(): bool { + return $this->isDeleted; + } + + public function getDeletedTime(): DateTime { + return $this->deleted; + } +} diff --git a/src/Users/Db/DbUserPasswordInfo.php b/src/Users/Db/DbUserPasswordInfo.php new file mode 100644 index 0000000..182f762 --- /dev/null +++ b/src/Users/Db/DbUserPasswordInfo.php @@ -0,0 +1,38 @@ +userId = $result->getString(0); + $this->hash = $result->getString(1); + $this->changed = DateTime::fromUnixTimeSeconds($result->getInteger(2)); + } + + public function getUserId(): string { + return $this->userId; + } + + public function getHash(): string { + return $this->hash; + } + + public function getChangedTime(): DateTime { + return $this->changed; + } + + public function verifyPassword(string $password): bool { + return password_verify($password, $this->hash); + } + + public function needsRehash(string|int|null $algo, array $options = []): bool { + return password_needs_rehash($this->hash, $algo, $options); + } +} diff --git a/src/Users/Db/DbUserTOTPInfo.php b/src/Users/Db/DbUserTOTPInfo.php new file mode 100644 index 0000000..759143b --- /dev/null +++ b/src/Users/Db/DbUserTOTPInfo.php @@ -0,0 +1,35 @@ +userId = $result->getString(0); + $this->secretKey = $result->getString(1); + $this->changed = DateTime::fromUnixTimeSeconds($result->getInteger(2)); + } + + public function getUserId(): string { + return $this->userId; + } + + public function getSecretKey(): string { + return $this->secretKey; + } + + public function getChangedTime(): DateTime { + return $this->changed; + } + + public function createGenerator(): TOTPGenerator { + return new TOTPGenerator($this->secretKey); + } +} diff --git a/src/Users/Db/DbUsers.php b/src/Users/Db/DbUsers.php new file mode 100644 index 0000000..93c7fae --- /dev/null +++ b/src/Users/Db/DbUsers.php @@ -0,0 +1,170 @@ +statements)) + return $this->statements[$name]; + return $this->statements[$name] = $this->conn->prepare($query()); + } + + + private function fetchUserInfoSingle(IDbResult $result, string $exceptionText): IUserInfo { + if(!$result->next()) + throw new RuntimeException($exceptionText); + + return new DbUserInfo($result); + } + + public function getUserInfoById(string $userId): IUserInfo { + $stmt = $this->getStatement('get info by id', fn() => ('SELECT ' . implode(',', self::USERS_FIELDS) . ' FROM ' . self::USERS_TABLE . ' WHERE user_id = ?')); + $stmt->reset(); + $stmt->addParameter(1, $userId, DbType::STRING); + $stmt->execute(); + + return $this->fetchUserInfoSingle($stmt->getResult(), 'no user with $userId found'); + } + + public function getUserInfoByName(string $userName): IUserInfo { + $stmt = $this->getStatement('get info by name', fn() => ('SELECT ' . implode(',', self::USERS_FIELDS) . ' FROM ' . self::USERS_TABLE . ' WHERE user_name = ?')); + $stmt->reset(); + $stmt->addParameter(1, $userName, DbType::STRING); + $stmt->execute(); + + return $this->fetchUserInfoSingle($stmt->getResult(), 'no user with $userName found'); + } + + + private function fetchAuthInfoMultiple(IDbResult $result): array { + $array = []; + + while($result->next()) + $array[] = new DbUserAuthInfo($result); + + return $array; + } + + public function getUserFirstFactorAuthInfos(IUserInfo $userInfo): array { + $stmt = $this->getStatement('get auth first', fn() => ('SELECT ' . implode(',', self::AUTH_FIELDS) . ' FROM ' . self::AUTH_TABLE . ' WHERE user_id = ? AND user_auth_type IN ("' . implode('", "', self::FIRST_FACTOR_AUTH) . '")')); + $stmt->reset(); + $stmt->addParameter(1, $userInfo->getId(), DbType::STRING); + $stmt->execute(); + + return $this->fetchAuthInfoMultiple($stmt->getResult()); + } + + public function getUserSecondFactorAuthInfos(IUserInfo $userInfo): array { + $stmt = $this->getStatement('get auth second', fn() => ('SELECT ' . implode(',', self::AUTH_FIELDS) . ' FROM ' . self::AUTH_TABLE . ' WHERE user_id = ? AND user_auth_type IN ("' . implode('", "', self::SECOND_FACTOR_AUTH) . '")')); + $stmt->reset(); + $stmt->addParameter(1, $userInfo->getId(), DbType::STRING); + $stmt->execute(); + + return $this->fetchAuthInfoMultiple($stmt->getResult()); + } + + + public function getUserPasswordInfo(IUserInfo $userInfo): IUserPasswordInfo { + $stmt = $this->getStatement('get pwd', fn() => ('SELECT ' . implode(',', self::PASSWORDS_FIELDS) . ' FROM ' . self::PASSWORDS_TABLE . ' WHERE user_id = ?')); + $stmt->reset(); + $stmt->addParameter(1, $userInfo->getId(), DbType::STRING); + $stmt->execute(); + $result = $stmt->getResult(); + + if(!$result->next()) + throw new RuntimeException('no password info for $userInfo found'); + + return new DbUserPasswordInfo($result); + } + + + public function getUserTOTPInfo(IUserInfo $userInfo): IUserTOTPInfo { + $stmt = $this->getStatement('get totp', fn() => ('SELECT ' . implode(',', self::TOTP_FIELDS) . ' FROM ' . self::TOTP_TABLE . ' WHERE user_id = ?')); + $stmt->reset(); + $stmt->addParameter(1, $userInfo->getId(), DbType::STRING); + $stmt->execute(); + $result = $stmt->getResult(); + + if(!$result->next()) + throw new RuntimeException('no totp info for $userInfo found'); + + return new DbUserTOTPInfo($result); + } + + + public function getUserEMailInfos(IUserInfo $userInfo): array { + $stmt = $this->getStatement('get emails', fn() => ('SELECT ' . implode(',', self::EMAILS_FIELDS) . ' FROM ' . self::EMAILS_TABLE . ' WHERE user_id = ?')); + $stmt->reset(); + $stmt->addParameter(1, $userInfo->getId(), DbType::STRING); + $stmt->execute(); + $result = $stmt->getResult(); + + $array = []; + + while($result->next()) + $array[] = new DbUserEMailInfo($result); + + return $array; + } + + + public function getUserBackupInfos(IUserInfo $userInfo): array { + $stmt = $this->getStatement('get backups', fn() => ('SELECT ' . implode(',', self::BACKUP_FIELDS) . ' FROM ' . self::BACKUP_TABLE . ' WHERE user_id = ?')); + $stmt->reset(); + $stmt->addParameter(1, $userInfo->getId(), DbType::STRING); + $stmt->execute(); + $result = $stmt->getResult(); + + $array = []; + + while($result->next()) + $array[] = new DbUserBackupInfo($result); + + return $array; + } +} diff --git a/src/Users/IUserAuthInfo.php b/src/Users/IUserAuthInfo.php new file mode 100644 index 0000000..db3eb2b --- /dev/null +++ b/src/Users/IUserAuthInfo.php @@ -0,0 +1,10 @@ +